From 44686045f673397dd1ef85bbb372861449ad7e64 Mon Sep 17 00:00:00 2001 From: ding113 Date: Sun, 5 Jul 2026 09:13:38 -0700 Subject: [PATCH 01/10] feat(prices): migrate cloud price sync to CPT v1 format Replace the LiteLLM TOML price table with the CCHP Cloud Pricing Table (CPT v1) JSON schema as the authoritative source for cloud model pricing. The new format carries per-model multi-provider pricing variants, vendor slugs, official pricing flags, tiered tracks (>200K/>272K context, priority service tier), and a providers dictionary with icon metadata. Key changes: - New cpt-schema.ts validates CPT v1 payloads; cpt-convert.ts transforms them into internal ModelPriceData with per-token fields, tier/priority mappings, and a per-provider pricing map - cloud-price-updater gains version-fingerprint short-circuit (skips write when version + row count match), stale-row cleanup for models removed from the cloud table, and persistence of catalog metadata (providers/vendors/version) into the new cloud_pricing_catalog table - ModelPriceSource adds "cloud" as the new default; "litellm" remains as a legacy value for pre-migration rows. The local-priority guard now keys on source !== "manual" instead of === "litellm" - Vendor inference refactored from prefix-list rules into a regex pipeline (src/lib/model-vendor/) shared with the cloud table generator: keyword scan, host-prefix stripping, Bedrock region normalization, and LobeHub brand fallback. Icon resolution chains bundled components -> cloud SVG map -> monogram - Model name fallback matching handles vendor-prefixed slash names, gateway suffixes (:thinking/:free), and Bedrock region prefixes via candidate generation + aliases GIN index lookup - Pricing resolution prefers data-driven official pricing nodes (official_pricing_provider / official=true) over name-based inference; vendor field drives official provider key derivation - Prices UI switches from hardcoded litellmProvider filter buttons to dynamic vendor summaries from /api/prices/vendors; rows show vendor icons, official badges, and multi-source counts - Migration 0106 adds cloud_pricing_catalog table and expression indexes on model_prices (price_data->>'vendor', price_data->'aliases') - i18n updated across en/ja/ru/zh-CN/zh-TW: LiteLLM labels renamed to cloud terminology, vendor/official/slug/model-family fields added --- drizzle/0107_handy_sunset_bain.sql | 14 + drizzle/meta/0107_snapshot.json | 4635 +++++++++++++++++ drizzle/meta/_journal.json | 9 +- messages/en/auditLogs.json | 2 +- messages/en/settings/prices.json | 49 +- messages/ja/auditLogs.json | 2 +- messages/ja/settings/prices.json | 49 +- messages/ru/auditLogs.json | 2 +- messages/ru/settings/prices.json | 49 +- messages/zh-CN/auditLogs.json | 2 +- messages/zh-CN/settings/prices.json | 49 +- messages/zh-TW/auditLogs.json | 2 +- messages/zh-TW/settings/prices.json | 49 +- src/actions/model-prices.ts | 108 +- .../model-price-details-dialog.tsx | 5 + .../prices/_components/price-list.tsx | 194 +- .../_components/provider-pricing-dialog.tsx | 25 +- .../_components/sync-conflict-dialog.tsx | 56 +- .../_components/upload-price-dialog.tsx | 2 +- src/app/[locale]/settings/prices/page.tsx | 12 +- .../_components/model-multi-select.tsx | 64 +- src/app/api/prices/cloud-model-count/route.ts | 18 +- src/app/api/prices/route.ts | 9 +- src/app/api/prices/vendors/route.ts | 61 + .../api/v1/resources/model-prices/handlers.ts | 1 + src/components/customs/model-vendor-icon.tsx | 104 +- .../ui/__tests__/language-switcher.test.tsx | 5 +- src/drizzle/schema.ts | 31 +- src/lib/api-client/v1/openapi-types.gen.ts | 20 +- src/lib/api/v1/schemas/model-prices.ts | 16 +- src/lib/model-vendor-icons.test.ts | 205 +- src/lib/model-vendor-icons.tsx | 256 +- src/lib/model-vendor-rules.ts | 119 - src/lib/model-vendor/vendor-icon-files.ts | 42 + src/lib/model-vendor/vendor-icon-map.json | 153 + src/lib/model-vendor/vendor-inference.test.ts | 155 + src/lib/model-vendor/vendor-inference.ts | 392 ++ src/lib/price-sync/cloud-price-table.ts | 33 +- src/lib/price-sync/cloud-price-updater.ts | 134 +- src/lib/price-sync/cpt-convert.ts | 527 ++ src/lib/price-sync/cpt-schema.ts | 176 + src/lib/public-status/config-publisher.ts | 16 +- src/lib/public-status/vendor-icon-key.ts | 40 +- src/lib/utils/model-name-matching.ts | 59 + src/lib/utils/pricing-resolution.ts | 114 +- src/repository/cloud-pricing-catalog.ts | 65 + src/repository/model-price.ts | 95 +- src/types/model-price.ts | 22 +- src/types/special-settings.ts | 1 + tests/unit/actions/model-prices.test.ts | 97 +- .../pricing-resolution-cloud-official.test.ts | 161 + .../lib/utils/model-name-matching.test.ts | 50 + .../unit/price-sync/cloud-price-table.test.ts | 32 +- .../price-sync/cloud-price-updater.test.ts | 153 +- tests/unit/price-sync/cpt-convert.test.ts | 447 ++ tests/unit/price-sync/cpt-schema.test.ts | 114 + .../prices/price-list-interactions.test.tsx | 108 +- .../price-list-multi-provider-ui.test.tsx | 17 +- 58 files changed, 8555 insertions(+), 872 deletions(-) create mode 100644 drizzle/0107_handy_sunset_bain.sql create mode 100644 drizzle/meta/0107_snapshot.json create mode 100644 src/app/api/prices/vendors/route.ts delete mode 100644 src/lib/model-vendor-rules.ts create mode 100644 src/lib/model-vendor/vendor-icon-files.ts create mode 100644 src/lib/model-vendor/vendor-icon-map.json create mode 100644 src/lib/model-vendor/vendor-inference.test.ts create mode 100644 src/lib/model-vendor/vendor-inference.ts create mode 100644 src/lib/price-sync/cpt-convert.ts create mode 100644 src/lib/price-sync/cpt-schema.ts create mode 100644 src/lib/utils/model-name-matching.ts create mode 100644 src/repository/cloud-pricing-catalog.ts create mode 100644 tests/unit/lib/pricing-resolution-cloud-official.test.ts create mode 100644 tests/unit/lib/utils/model-name-matching.test.ts create mode 100644 tests/unit/price-sync/cpt-convert.test.ts create mode 100644 tests/unit/price-sync/cpt-schema.test.ts diff --git a/drizzle/0107_handy_sunset_bain.sql b/drizzle/0107_handy_sunset_bain.sql new file mode 100644 index 000000000..5e48eb09f --- /dev/null +++ b/drizzle/0107_handy_sunset_bain.sql @@ -0,0 +1,14 @@ +CREATE TABLE IF NOT EXISTS "cloud_pricing_catalog" ( + "id" serial PRIMARY KEY NOT NULL, + "version" varchar(64) NOT NULL, + "currency" varchar(16) DEFAULT 'USD' NOT NULL, + "refreshed_at" timestamp with time zone, + "providers" jsonb NOT NULL, + "vendors" jsonb NOT NULL, + "model_count" integer DEFAULT 0 NOT NULL, + "synced_at" timestamp with time zone DEFAULT now() +); +--> statement-breakpoint +ALTER TABLE "model_prices" ALTER COLUMN "source" SET DEFAULT 'cloud';--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "idx_model_prices_vendor" ON "model_prices" USING btree ((("price_data" ->> 'vendor')));--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "idx_model_prices_aliases" ON "model_prices" USING gin ((("price_data" -> 'aliases'))); \ No newline at end of file diff --git a/drizzle/meta/0107_snapshot.json b/drizzle/meta/0107_snapshot.json new file mode 100644 index 000000000..e0958437e --- /dev/null +++ b/drizzle/meta/0107_snapshot.json @@ -0,0 +1,4635 @@ +{ + "id": "f37176a3-a5e2-4209-8e0e-f4d9f170def4", + "prevId": "30511ee7-83b4-4f2f-8e28-e9ba594ec671", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "action_category": { + "name": "action_category", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "action_type": { + "name": "action_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false + }, + "target_id": { + "name": "target_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "target_name": { + "name": "target_name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "before_value": { + "name": "before_value", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "after_value": { + "name": "after_value", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "operator_user_id": { + "name": "operator_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "operator_user_name": { + "name": "operator_user_name", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "operator_key_id": { + "name": "operator_key_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "operator_key_name": { + "name": "operator_key_name", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "operator_ip": { + "name": "operator_ip", + "type": "varchar(45)", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "success": { + "name": "success", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_audit_log_category_created_at": { + "name": "idx_audit_log_category_created_at", + "columns": [ + { + "expression": "action_category", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_log_operator_user_created_at": { + "name": "idx_audit_log_operator_user_created_at", + "columns": [ + { + "expression": "operator_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"audit_log\".\"operator_user_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_log_operator_ip_created_at": { + "name": "idx_audit_log_operator_ip_created_at", + "columns": [ + { + "expression": "operator_ip", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"audit_log\".\"operator_ip\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_log_target": { + "name": "idx_audit_log_target", + "columns": [ + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"audit_log\".\"target_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_log_created_at_id": { + "name": "idx_audit_log_created_at_id", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cloud_pricing_catalog": { + "name": "cloud_pricing_catalog", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "version": { + "name": "version", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "refreshed_at": { + "name": "refreshed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "providers": { + "name": "providers", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "vendors": { + "name": "vendors", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "model_count": { + "name": "model_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "synced_at": { + "name": "synced_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.error_rules": { + "name": "error_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pattern": { + "name": "pattern", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'regex'" + }, + "category": { + "name": "category", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "override_response": { + "name": "override_response", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "override_status_code": { + "name": "override_status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_error_rules_enabled": { + "name": "idx_error_rules_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "unique_pattern": { + "name": "unique_pattern", + "columns": [ + { + "expression": "pattern", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_category": { + "name": "idx_category", + "columns": [ + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_match_type": { + "name": "idx_match_type", + "columns": [ + { + "expression": "match_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.keys": { + "name": "keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "can_login_web_ui": { + "name": "can_login_web_ui", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_5h_reset_mode": { + "name": "limit_5h_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'rolling'" + }, + "limit_daily_usd": { + "name": "limit_daily_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_total_usd": { + "name": "limit_total_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "cost_reset_at": { + "name": "cost_reset_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "provider_group": { + "name": "provider_group", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "default": "'default'" + }, + "cache_ttl_preference": { + "name": "cache_ttl_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_keys_user_id": { + "name": "idx_keys_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_keys_key": { + "name": "idx_keys_key", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_keys_created_at": { + "name": "idx_keys_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_keys_deleted_at": { + "name": "idx_keys_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.message_request": { + "name": "message_request", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "numeric(21, 15)", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "group_cost_multiplier": { + "name": "group_cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "cost_breakdown": { + "name": "cost_breakdown", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "request_sequence": { + "name": "request_sequence", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "provider_chain": { + "name": "provider_chain", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "api_type": { + "name": "api_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "endpoint": { + "name": "endpoint", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "original_model": { + "name": "original_model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "actual_response_model": { + "name": "actual_response_model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "ttfb_ms": { + "name": "ttfb_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_creation_input_tokens": { + "name": "cache_creation_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_read_input_tokens": { + "name": "cache_read_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_creation_5m_input_tokens": { + "name": "cache_creation_5m_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_creation_1h_input_tokens": { + "name": "cache_creation_1h_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_ttl_applied": { + "name": "cache_ttl_applied", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "context_1m_applied": { + "name": "context_1m_applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "swap_cache_ttl_applied": { + "name": "swap_cache_ttl_applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "special_settings": { + "name": "special_settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "hedge_losers": { + "name": "hedge_losers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_stack": { + "name": "error_stack", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_cause": { + "name": "error_cause", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "blocked_by": { + "name": "blocked_by", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "blocked_reason": { + "name": "blocked_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "client_ip": { + "name": "client_ip", + "type": "varchar(45)", + "primaryKey": false, + "notNull": false + }, + "messages_count": { + "name": "messages_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_message_request_user_date_cost": { + "name": "idx_message_request_user_date_cost", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_user_created_at_cost_stats": { + "name": "idx_message_request_user_created_at_cost_stats", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_user_query": { + "name": "idx_message_request_user_query", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_provider_created_at_active": { + "name": "idx_message_request_provider_created_at_active", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_provider_created_at_finalized_active": { + "name": "idx_message_request_provider_created_at_finalized_active", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"status_code\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_id": { + "name": "idx_message_request_session_id", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_id_prefix": { + "name": "idx_message_request_session_id_prefix", + "columns": [ + { + "expression": "\"session_id\" varchar_pattern_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_seq": { + "name": "idx_message_request_session_seq", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "request_sequence", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_endpoint": { + "name": "idx_message_request_endpoint", + "columns": [ + { + "expression": "endpoint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_blocked_by": { + "name": "idx_message_request_blocked_by", + "columns": [ + { + "expression": "blocked_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_provider_id": { + "name": "idx_message_request_provider_id", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_user_id": { + "name": "idx_message_request_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key": { + "name": "idx_message_request_key", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_created_at_id": { + "name": "idx_message_request_key_created_at_id", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_model_active": { + "name": "idx_message_request_key_model_active", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"model\" IS NOT NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_endpoint_active": { + "name": "idx_message_request_key_endpoint_active", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "endpoint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"endpoint\" IS NOT NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_created_at_id_active": { + "name": "idx_message_request_created_at_id_active", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_model_active": { + "name": "idx_message_request_model_active", + "columns": [ + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"model\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_status_code_active": { + "name": "idx_message_request_status_code_active", + "columns": [ + { + "expression": "status_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"status_code\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_created_at": { + "name": "idx_message_request_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_deleted_at": { + "name": "idx_message_request_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_last_active": { + "name": "idx_message_request_key_last_active", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_cost_active": { + "name": "idx_message_request_key_cost_active", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_user_info": { + "name": "idx_message_request_session_user_info", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_client_ip_created_at": { + "name": "idx_message_request_client_ip_created_at", + "columns": [ + { + "expression": "client_ip", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"client_ip\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.model_prices": { + "name": "model_prices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "model_name": { + "name": "model_name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "price_data": { + "name": "price_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'cloud'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_model_prices_latest": { + "name": "idx_model_prices_latest", + "columns": [ + { + "expression": "model_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_model_name": { + "name": "idx_model_prices_model_name", + "columns": [ + { + "expression": "model_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_created_at": { + "name": "idx_model_prices_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_source": { + "name": "idx_model_prices_source", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_vendor": { + "name": "idx_model_prices_vendor", + "columns": [ + { + "expression": "((\"price_data\" ->> 'vendor'))", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_aliases": { + "name": "idx_model_prices_aliases", + "columns": [ + { + "expression": "((\"price_data\" -> 'aliases'))", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_settings": { + "name": "notification_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "use_legacy_mode": { + "name": "use_legacy_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "circuit_breaker_enabled": { + "name": "circuit_breaker_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "circuit_breaker_webhook": { + "name": "circuit_breaker_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "daily_leaderboard_enabled": { + "name": "daily_leaderboard_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "daily_leaderboard_webhook": { + "name": "daily_leaderboard_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "daily_leaderboard_time": { + "name": "daily_leaderboard_time", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false, + "default": "'09:00'" + }, + "daily_leaderboard_top_n": { + "name": "daily_leaderboard_top_n", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 5 + }, + "cost_alert_enabled": { + "name": "cost_alert_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cost_alert_webhook": { + "name": "cost_alert_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "cost_alert_threshold": { + "name": "cost_alert_threshold", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": false, + "default": "'0.80'" + }, + "cost_alert_check_interval": { + "name": "cost_alert_check_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 60 + }, + "cache_hit_rate_alert_enabled": { + "name": "cache_hit_rate_alert_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cache_hit_rate_alert_webhook": { + "name": "cache_hit_rate_alert_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "cache_hit_rate_alert_window_mode": { + "name": "cache_hit_rate_alert_window_mode", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false, + "default": "'auto'" + }, + "cache_hit_rate_alert_check_interval": { + "name": "cache_hit_rate_alert_check_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 5 + }, + "cache_hit_rate_alert_historical_lookback_days": { + "name": "cache_hit_rate_alert_historical_lookback_days", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 7 + }, + "cache_hit_rate_alert_min_eligible_requests": { + "name": "cache_hit_rate_alert_min_eligible_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 20 + }, + "cache_hit_rate_alert_min_eligible_tokens": { + "name": "cache_hit_rate_alert_min_eligible_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "cache_hit_rate_alert_abs_min": { + "name": "cache_hit_rate_alert_abs_min", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "cache_hit_rate_alert_drop_rel": { + "name": "cache_hit_rate_alert_drop_rel", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.3'" + }, + "cache_hit_rate_alert_drop_abs": { + "name": "cache_hit_rate_alert_drop_abs", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.1'" + }, + "cache_hit_rate_alert_cooldown_minutes": { + "name": "cache_hit_rate_alert_cooldown_minutes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30 + }, + "cache_hit_rate_alert_top_n": { + "name": "cache_hit_rate_alert_top_n", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_target_bindings": { + "name": "notification_target_bindings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "notification_type": { + "name": "notification_type", + "type": "notification_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "schedule_cron": { + "name": "schedule_cron", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "schedule_timezone": { + "name": "schedule_timezone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "template_override": { + "name": "template_override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "unique_notification_target_binding": { + "name": "unique_notification_target_binding", + "columns": [ + { + "expression": "notification_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_notification_bindings_type": { + "name": "idx_notification_bindings_type", + "columns": [ + { + "expression": "notification_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_notification_bindings_target": { + "name": "idx_notification_bindings_target", + "columns": [ + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notification_target_bindings_target_id_webhook_targets_id_fk": { + "name": "notification_target_bindings_target_id_webhook_targets_id_fk", + "tableFrom": "notification_target_bindings", + "tableTo": "webhook_targets", + "columnsFrom": [ + "target_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_endpoint_probe_logs": { + "name": "provider_endpoint_probe_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "endpoint_id": { + "name": "endpoint_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'scheduled'" + }, + "ok": { + "name": "ok", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "error_type": { + "name": "error_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_provider_endpoint_probe_logs_endpoint_created_at": { + "name": "idx_provider_endpoint_probe_logs_endpoint_created_at", + "columns": [ + { + "expression": "endpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoint_probe_logs_created_at": { + "name": "idx_provider_endpoint_probe_logs_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "provider_endpoint_probe_logs_endpoint_id_provider_endpoints_id_fk": { + "name": "provider_endpoint_probe_logs_endpoint_id_provider_endpoints_id_fk", + "tableFrom": "provider_endpoint_probe_logs", + "tableTo": "provider_endpoints", + "columnsFrom": [ + "endpoint_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_endpoints": { + "name": "provider_endpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "vendor_id": { + "name": "vendor_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "provider_type": { + "name": "provider_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'claude'" + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_probed_at": { + "name": "last_probed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_probe_ok": { + "name": "last_probe_ok", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "last_probe_status_code": { + "name": "last_probe_status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_probe_latency_ms": { + "name": "last_probe_latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_probe_error_type": { + "name": "last_probe_error_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "last_probe_error_message": { + "name": "last_probe_error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "uniq_provider_endpoints_vendor_type_url": { + "name": "uniq_provider_endpoints_vendor_type_url", + "columns": [ + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_vendor_type": { + "name": "idx_provider_endpoints_vendor_type", + "columns": [ + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_enabled": { + "name": "idx_provider_endpoints_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_pick_enabled": { + "name": "idx_provider_endpoints_pick_enabled", + "columns": [ + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_created_at": { + "name": "idx_provider_endpoints_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_deleted_at": { + "name": "idx_provider_endpoints_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "provider_endpoints_vendor_id_provider_vendors_id_fk": { + "name": "provider_endpoints_vendor_id_provider_vendors_id_fk", + "tableFrom": "provider_endpoints", + "tableTo": "provider_vendors", + "columnsFrom": [ + "vendor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_groups": { + "name": "provider_groups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": true, + "default": "'1.0'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "provider_groups_name_unique": { + "name": "provider_groups_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_vendors": { + "name": "provider_vendors", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "website_domain": { + "name": "website_domain", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "website_url": { + "name": "website_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "favicon_url": { + "name": "favicon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "uniq_provider_vendors_website_domain": { + "name": "uniq_provider_vendors_website_domain", + "columns": [ + { + "expression": "website_domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_vendors_created_at": { + "name": "idx_provider_vendors_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.providers": { + "name": "providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "provider_vendor_id": { + "name": "provider_vendor_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "weight": { + "name": "weight", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "group_priorities": { + "name": "group_priorities", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false, + "default": "'1.0'" + }, + "group_tag": { + "name": "group_tag", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "provider_type": { + "name": "provider_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'claude'" + }, + "preserve_client_ip": { + "name": "preserve_client_ip", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "disable_session_reuse": { + "name": "disable_session_reuse", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "model_redirects": { + "name": "model_redirects", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "allowed_models": { + "name": "allowed_models", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "allowed_clients": { + "name": "allowed_clients", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "blocked_clients": { + "name": "blocked_clients", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "active_time_start": { + "name": "active_time_start", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "active_time_end": { + "name": "active_time_end", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "codex_instructions_strategy": { + "name": "codex_instructions_strategy", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "default": "'auto'" + }, + "mcp_passthrough_type": { + "name": "mcp_passthrough_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "mcp_passthrough_url": { + "name": "mcp_passthrough_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_5h_reset_mode": { + "name": "limit_5h_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'rolling'" + }, + "limit_daily_usd": { + "name": "limit_daily_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_total_usd": { + "name": "limit_total_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "total_cost_reset_at": { + "name": "total_cost_reset_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "max_retry_attempts": { + "name": "max_retry_attempts", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "circuit_breaker_failure_threshold": { + "name": "circuit_breaker_failure_threshold", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 5 + }, + "circuit_breaker_open_duration": { + "name": "circuit_breaker_open_duration", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1800000 + }, + "circuit_breaker_half_open_success_threshold": { + "name": "circuit_breaker_half_open_success_threshold", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 2 + }, + "proxy_url": { + "name": "proxy_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "proxy_fallback_to_direct": { + "name": "proxy_fallback_to_direct", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "custom_headers": { + "name": "custom_headers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "first_byte_timeout_streaming_ms": { + "name": "first_byte_timeout_streaming_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "streaming_idle_timeout_ms": { + "name": "streaming_idle_timeout_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "request_timeout_non_streaming_ms": { + "name": "request_timeout_non_streaming_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "website_url": { + "name": "website_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "favicon_url": { + "name": "favicon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cache_ttl_preference": { + "name": "cache_ttl_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "swap_cache_ttl_billing": { + "name": "swap_cache_ttl_billing", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "context_1m_preference": { + "name": "context_1m_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "codex_reasoning_effort_preference": { + "name": "codex_reasoning_effort_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "codex_reasoning_summary_preference": { + "name": "codex_reasoning_summary_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "codex_text_verbosity_preference": { + "name": "codex_text_verbosity_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "codex_parallel_tool_calls_preference": { + "name": "codex_parallel_tool_calls_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "codex_image_generation_preference": { + "name": "codex_image_generation_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "codex_service_tier_preference": { + "name": "codex_service_tier_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "anthropic_max_tokens_preference": { + "name": "anthropic_max_tokens_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "anthropic_thinking_budget_preference": { + "name": "anthropic_thinking_budget_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "anthropic_adaptive_thinking": { + "name": "anthropic_adaptive_thinking", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "gemini_google_search_preference": { + "name": "gemini_google_search_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "tpm": { + "name": "tpm", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "rpm": { + "name": "rpm", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "rpd": { + "name": "rpd", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "cc": { + "name": "cc", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_providers_enabled_priority": { + "name": "idx_providers_enabled_priority", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "weight", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_group": { + "name": "idx_providers_group", + "columns": [ + { + "expression": "group_tag", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_vendor_type_url_active": { + "name": "idx_providers_vendor_type_url_active", + "columns": [ + { + "expression": "provider_vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_created_at": { + "name": "idx_providers_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_deleted_at": { + "name": "idx_providers_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_vendor_type": { + "name": "idx_providers_vendor_type", + "columns": [ + { + "expression": "provider_vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_enabled_vendor_type": { + "name": "idx_providers_enabled_vendor_type", + "columns": [ + { + "expression": "provider_vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL AND \"providers\".\"is_enabled\" = true AND \"providers\".\"provider_vendor_id\" IS NOT NULL AND \"providers\".\"provider_vendor_id\" > 0", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "providers_provider_vendor_id_provider_vendors_id_fk": { + "name": "providers_provider_vendor_id_provider_vendors_id_fk", + "tableFrom": "providers", + "tableTo": "provider_vendors", + "columnsFrom": [ + "provider_vendor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.request_filters": { + "name": "request_filters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "target": { + "name": "target", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "replacement": { + "name": "replacement", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "binding_type": { + "name": "binding_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'global'" + }, + "provider_ids": { + "name": "provider_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "group_tags": { + "name": "group_tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "rule_mode": { + "name": "rule_mode", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'simple'" + }, + "execution_phase": { + "name": "execution_phase", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'guard'" + }, + "operations": { + "name": "operations", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_request_filters_enabled": { + "name": "idx_request_filters_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_scope": { + "name": "idx_request_filters_scope", + "columns": [ + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_action": { + "name": "idx_request_filters_action", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_binding": { + "name": "idx_request_filters_binding", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "binding_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_phase": { + "name": "idx_request_filters_phase", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_phase", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sensitive_words": { + "name": "sensitive_words", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "word": { + "name": "word", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'contains'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_sensitive_words_enabled": { + "name": "idx_sensitive_words_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "match_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_sensitive_words_created_at": { + "name": "idx_sensitive_words_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.system_settings": { + "name": "system_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "site_title": { + "name": "site_title", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "default": "'Claude Code Hub'" + }, + "allow_global_usage_view": { + "name": "allow_global_usage_view", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "currency_display": { + "name": "currency_display", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "billing_model_source": { + "name": "billing_model_source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'original'" + }, + "codex_priority_billing_source": { + "name": "codex_priority_billing_source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'requested'" + }, + "bill_non_successful_requests": { + "name": "bill_non_successful_requests", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "bill_hedge_losers": { + "name": "bill_hedge_losers", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "timezone": { + "name": "timezone", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "enable_auto_cleanup": { + "name": "enable_auto_cleanup", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "cleanup_retention_days": { + "name": "cleanup_retention_days", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30 + }, + "cleanup_schedule": { + "name": "cleanup_schedule", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "default": "'0 2 * * *'" + }, + "cleanup_batch_size": { + "name": "cleanup_batch_size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10000 + }, + "enable_client_version_check": { + "name": "enable_client_version_check", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "verbose_provider_error": { + "name": "verbose_provider_error", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "pass_through_upstream_error_message": { + "name": "pass_through_upstream_error_message", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_http2": { + "name": "enable_http2", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_openai_responses_websocket": { + "name": "enable_openai_responses_websocket", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_high_concurrency_mode": { + "name": "enable_high_concurrency_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "intercept_anthropic_warmup_requests": { + "name": "intercept_anthropic_warmup_requests", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_thinking_signature_rectifier": { + "name": "enable_thinking_signature_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_thinking_budget_rectifier": { + "name": "enable_thinking_budget_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_thinking_effort_conflict_rectifier": { + "name": "enable_thinking_effort_conflict_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_billing_header_rectifier": { + "name": "enable_billing_header_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_response_input_rectifier": { + "name": "enable_response_input_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "allow_non_conversation_endpoint_provider_fallback": { + "name": "allow_non_conversation_endpoint_provider_fallback", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "fake_streaming_whitelist": { + "name": "fake_streaming_whitelist", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "enable_codex_session_id_completion": { + "name": "enable_codex_session_id_completion", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_claude_metadata_user_id_injection": { + "name": "enable_claude_metadata_user_id_injection", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_response_fixer": { + "name": "enable_response_fixer", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "response_fixer_config": { + "name": "response_fixer_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{\"fixTruncatedJson\":true,\"fixSseFormat\":true,\"fixEncoding\":true,\"maxJsonDepth\":200,\"maxFixSize\":1048576}'::jsonb" + }, + "quota_db_refresh_interval_seconds": { + "name": "quota_db_refresh_interval_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10 + }, + "quota_lease_percent_5h": { + "name": "quota_lease_percent_5h", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "quota_lease_percent_daily": { + "name": "quota_lease_percent_daily", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "quota_lease_percent_weekly": { + "name": "quota_lease_percent_weekly", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "quota_lease_percent_monthly": { + "name": "quota_lease_percent_monthly", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "quota_lease_cap_usd": { + "name": "quota_lease_cap_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "ip_extraction_config": { + "name": "ip_extraction_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ip_geo_lookup_enabled": { + "name": "ip_geo_lookup_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "public_status_window_hours": { + "name": "public_status_window_hours", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 24 + }, + "public_status_aggregation_interval_minutes": { + "name": "public_status_aggregation_interval_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_ledger": { + "name": "usage_ledger", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "final_provider_id": { + "name": "final_provider_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "original_model": { + "name": "original_model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "actual_response_model": { + "name": "actual_response_model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "endpoint": { + "name": "endpoint", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "api_type": { + "name": "api_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_success": { + "name": "is_success", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "success_rate_outcome": { + "name": "success_rate_outcome", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false + }, + "blocked_by": { + "name": "blocked_by", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "numeric(21, 15)", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "group_cost_multiplier": { + "name": "group_cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_creation_input_tokens": { + "name": "cache_creation_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_read_input_tokens": { + "name": "cache_read_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_creation_5m_input_tokens": { + "name": "cache_creation_5m_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_creation_1h_input_tokens": { + "name": "cache_creation_1h_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_ttl_applied": { + "name": "cache_ttl_applied", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "context_1m_applied": { + "name": "context_1m_applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "swap_cache_ttl_applied": { + "name": "swap_cache_ttl_applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "ttfb_ms": { + "name": "ttfb_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "client_ip": { + "name": "client_ip", + "type": "varchar(45)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_usage_ledger_request_id": { + "name": "idx_usage_ledger_request_id", + "columns": [ + { + "expression": "request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_user_created_at": { + "name": "idx_usage_ledger_user_created_at", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"blocked_by\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_key_created_at": { + "name": "idx_usage_ledger_key_created_at", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"blocked_by\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_provider_created_at": { + "name": "idx_usage_ledger_provider_created_at", + "columns": [ + { + "expression": "final_provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"blocked_by\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_created_at_minute": { + "name": "idx_usage_ledger_created_at_minute", + "columns": [ + { + "expression": "date_trunc('minute', \"created_at\" AT TIME ZONE 'UTC')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_created_at_desc_id": { + "name": "idx_usage_ledger_created_at_desc_id", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_session_id": { + "name": "idx_usage_ledger_session_id", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"session_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_model": { + "name": "idx_usage_ledger_model", + "columns": [ + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"model\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_key_cost": { + "name": "idx_usage_ledger_key_cost", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "endpoint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"blocked_by\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_user_cost_cover": { + "name": "idx_usage_ledger_user_cost_cover", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "endpoint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"blocked_by\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_provider_cost_cover": { + "name": "idx_usage_ledger_provider_cost_cover", + "columns": [ + { + "expression": "final_provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "endpoint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"blocked_by\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_key_created_at_desc_cover": { + "name": "idx_usage_ledger_key_created_at_desc_cover", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"created_at\" DESC NULLS LAST", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "final_provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"blocked_by\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "varchar", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "rpm_limit": { + "name": "rpm_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "daily_limit_usd": { + "name": "daily_limit_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "provider_group": { + "name": "provider_group", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "default": "'default'" + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_5h_reset_mode": { + "name": "limit_5h_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'rolling'" + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_total_usd": { + "name": "limit_total_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "cost_reset_at": { + "name": "cost_reset_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "limit_5h_cost_reset_at": { + "name": "limit_5h_cost_reset_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "allowed_clients": { + "name": "allowed_clients", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "allowed_models": { + "name": "allowed_models", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "blocked_clients": { + "name": "blocked_clients", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_users_active_role_sort": { + "name": "idx_users_active_role_sort", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"users\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_enabled_expires_at": { + "name": "idx_users_enabled_expires_at", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"users\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_tags_gin": { + "name": "idx_users_tags_gin", + "columns": [ + { + "expression": "tags", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"users\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_users_created_at": { + "name": "idx_users_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_deleted_at": { + "name": "idx_users_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook_targets": { + "name": "webhook_targets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "provider_type": { + "name": "provider_type", + "type": "webhook_provider_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "webhook_url": { + "name": "webhook_url", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": false + }, + "telegram_bot_token": { + "name": "telegram_bot_token", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "telegram_chat_id": { + "name": "telegram_chat_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "dingtalk_secret": { + "name": "dingtalk_secret", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "custom_template": { + "name": "custom_template", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "custom_headers": { + "name": "custom_headers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "proxy_url": { + "name": "proxy_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "proxy_fallback_to_direct": { + "name": "proxy_fallback_to_direct", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_test_at": { + "name": "last_test_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_test_result": { + "name": "last_test_result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.daily_reset_mode": { + "name": "daily_reset_mode", + "schema": "public", + "values": [ + "fixed", + "rolling" + ] + }, + "public.notification_type": { + "name": "notification_type", + "schema": "public", + "values": [ + "circuit_breaker", + "daily_leaderboard", + "cost_alert", + "cache_hit_rate_alert" + ] + }, + "public.webhook_provider_type": { + "name": "webhook_provider_type", + "schema": "public", + "values": [ + "wechat", + "feishu", + "dingtalk", + "telegram", + "custom" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 333faa947..34a506bcc 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -750,6 +750,13 @@ "when": 1783018751299, "tag": "0106_calm_firebird", "breakpoints": true + }, + { + "idx": 107, + "version": "7", + "when": 1783270005639, + "tag": "0107_handy_sunset_bain", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/messages/en/auditLogs.json b/messages/en/auditLogs.json index 4bb77e766..30fd244ea 100644 --- a/messages/en/auditLogs.json +++ b/messages/en/auditLogs.json @@ -72,7 +72,7 @@ }, "model_price": { "bulk_upload": "Bulk upload model prices", - "sync_litellm": "Sync LiteLLM model prices", + "sync_litellm": "Sync cloud price table", "upsert": "Upsert model price", "delete": "Delete model price" } diff --git a/messages/en/settings/prices.json b/messages/en/settings/prices.json index fb6e970a5..01eac998a 100644 --- a/messages/en/settings/prices.json +++ b/messages/en/settings/prices.json @@ -9,30 +9,13 @@ "filters": { "all": "All", "local": "Local", - "anthropic": "Anthropic", - "openai": "OpenAI", - "vertex": "Vertex", - "deepseek": "DeepSeek", - "mistral": "Mistral", - "meta": "Meta", - "cohere": "Cohere", - "xai": "xAI", - "groq": "Groq", - "bedrock": "Bedrock", - "azure": "Azure", - "together": "Together", - "nvidia": "NVIDIA", - "zhipuai": "Zhipu", - "volcengine": "Volcengine", - "minimax": "MiniMax", - "qwen": "Qwen", - "fireworks": "Fireworks", - "ollama": "Ollama", - "openrouter": "OpenRouter" + "moreVendors": "More vendors" }, "badges": { "local": "Local", - "multi": "Multi" + "multi": "Multi", + "official": "Official", + "multiWithCount": "{count} sources" }, "capabilities": { "assistantPrefill": "Assistant prefill", @@ -64,25 +47,25 @@ }, "conflict": { "title": "Select Items to Overwrite", - "description": "The following models have manual prices. Check the ones to overwrite with LiteLLM prices, unchecked ones will be kept unchanged", + "description": "The following models have manual prices. Check the ones to overwrite with cloud prices, unchecked ones will be kept unchanged", "searchPlaceholder": "Search models...", "table": { "modelName": "Model", "manualPrice": "Manual Price", - "litellmPrice": "LiteLLM Price", - "action": "Action" + "action": "Action", + "cloudPrice": "Cloud Price" }, "viewDiff": "View Diff", "diffTitle": "Price Difference", "diff": { "field": "Field", "manual": "Manual", - "litellm": "LiteLLM", "inputPrice": "Input Price", "outputPrice": "Output Price", "imagePrice": "Image Price", "provider": "Provider", - "mode": "Type" + "mode": "Type", + "cloud": "Cloud" }, "pagination": { "showing": "Showing {from}-{to} of {total}" @@ -148,7 +131,7 @@ "updateFailed": "Update failed", "systemHasBuiltIn": "System has built-in price table", "manualDownload": "You can also manually download", - "latestPriceTable": "cloud price table", + "latestPriceTable": "cloud price table (models.json)", "andUploadViaButton": ", and upload via button above", "cloudModelCountLoading": "Loading cloud model count...", "cloudModelCountFailed": "Failed to load cloud model count", @@ -218,7 +201,7 @@ "fields": { "mode": "Mode", "displayName": "Display name", - "litellmProvider": "LiteLLM provider", + "litellmProvider": "LiteLLM provider (legacy)", "selectedPricingProvider": "Selected pricing provider", "selectedPricingSourceModel": "Selected source model", "selectedPricingResolution": "Selected resolution", @@ -231,7 +214,12 @@ "inputCostPerRequest": "Input Cost Per Request", "outputCostPerImage": "Output Cost Per Image", "inputCostPerSecond": "Input Cost Per Second", - "fileSearchCostPer1kCalls": "File Search Cost Per 1k Calls" + "fileSearchCostPer1kCalls": "File Search Cost Per 1k Calls", + "vendor": "Vendor", + "slug": "Model slug", + "officialPricingProvider": "Official pricing provider", + "modelFamily": "Model family", + "knowledgeCutoff": "Knowledge cutoff" } }, "toast": { @@ -251,6 +239,7 @@ "pinSuccess": "Pinned {provider} pricing as local model price", "pinFailed": "Failed to pin provider pricing", "pinned": "Pinned", - "priority": "Fast mode pricing" + "priority": "Fast mode pricing", + "official": "Official" } } diff --git a/messages/ja/auditLogs.json b/messages/ja/auditLogs.json index 6e21cdf4e..d8b755dda 100644 --- a/messages/ja/auditLogs.json +++ b/messages/ja/auditLogs.json @@ -72,7 +72,7 @@ }, "model_price": { "bulk_upload": "モデル価格の一括アップロード", - "sync_litellm": "LiteLLM モデル価格の同期", + "sync_litellm": "クラウド価格表の同期", "upsert": "モデル価格更新", "delete": "モデル価格削除" } diff --git a/messages/ja/settings/prices.json b/messages/ja/settings/prices.json index 2ee467dd5..801f8d057 100644 --- a/messages/ja/settings/prices.json +++ b/messages/ja/settings/prices.json @@ -9,30 +9,13 @@ "filters": { "all": "すべて", "local": "ローカル", - "anthropic": "Anthropic", - "openai": "OpenAI", - "vertex": "Vertex", - "deepseek": "DeepSeek", - "mistral": "Mistral", - "meta": "Meta", - "cohere": "Cohere", - "xai": "xAI", - "groq": "Groq", - "bedrock": "Bedrock", - "azure": "Azure", - "together": "Together", - "nvidia": "NVIDIA", - "zhipuai": "Zhipu", - "volcengine": "Volcengine", - "minimax": "MiniMax", - "qwen": "Qwen", - "fireworks": "Fireworks", - "ollama": "Ollama", - "openrouter": "OpenRouter" + "moreVendors": "その他のベンダー" }, "badges": { "local": "ローカル", - "multi": "マルチ" + "multi": "マルチ", + "official": "公式価格", + "multiWithCount": "{count} ソース" }, "capabilities": { "assistantPrefill": "アシスタント事前入力", @@ -64,25 +47,25 @@ }, "conflict": { "title": "上書きする項目を選択", - "description": "以下のモデルには手動で設定された価格があります。チェックした項目はLiteLLM価格で上書きされ、チェックしない項目は現在のままです", + "description": "以下のモデルには手動価格があります。クラウド価格で上書きするものにチェックを入れてください。チェックしないものは変更されません", "searchPlaceholder": "モデルを検索...", "table": { "modelName": "モデル", "manualPrice": "手動価格", - "litellmPrice": "LiteLLM価格", - "action": "操作内容" + "action": "操作内容", + "cloudPrice": "クラウド価格" }, "viewDiff": "差異を表示", "diffTitle": "価格差異", "diff": { "field": "フィールド", "manual": "手動", - "litellm": "LiteLLM", "inputPrice": "入力価格", "outputPrice": "出力価格", "imagePrice": "画像価格", "provider": "プロバイダー", - "mode": "タイプ" + "mode": "タイプ", + "cloud": "クラウド" }, "pagination": { "showing": "{from}〜{to}件を表示 (全{total}件)" @@ -148,7 +131,7 @@ "updateFailed": "更新に失敗しました", "systemHasBuiltIn": "システムは組み込み価格表を持っています", "manualDownload": "手動でダウンロードすることもできます", - "latestPriceTable": "クラウド価格表", + "latestPriceTable": "クラウド価格表 (models.json)", "andUploadViaButton": "、上のボタンでアップロードしてください", "cloudModelCountLoading": "クラウドモデル数を読み込み中...", "cloudModelCountFailed": "クラウドモデル数の読み込みに失敗しました", @@ -218,7 +201,7 @@ "fields": { "mode": "モード", "displayName": "表示名", - "litellmProvider": "LiteLLM プロバイダー", + "litellmProvider": "LiteLLM プロバイダー (旧版)", "selectedPricingProvider": "選択中の価格プロバイダー", "selectedPricingSourceModel": "選択元モデル", "selectedPricingResolution": "選択解決方式", @@ -231,7 +214,12 @@ "inputCostPerRequest": "リクエスト単価", "outputCostPerImage": "画像出力単価", "inputCostPerSecond": "秒単位入力価格", - "fileSearchCostPer1kCalls": "ファイル検索 1k 回あたりの価格" + "fileSearchCostPer1kCalls": "ファイル検索 1k 回あたりの価格", + "vendor": "ベンダー", + "slug": "モデル識別子", + "officialPricingProvider": "公式価格プロバイダー", + "modelFamily": "モデルファミリー", + "knowledgeCutoff": "知識カットオフ" } }, "toast": { @@ -251,6 +239,7 @@ "pinSuccess": "{provider} の価格をローカルモデル価格として固定しました", "pinFailed": "プロバイダー価格の固定に失敗しました", "pinned": "固定済み", - "priority": "高速モード価格" + "priority": "高速モード価格", + "official": "公式" } } diff --git a/messages/ru/auditLogs.json b/messages/ru/auditLogs.json index e05922d7c..daa33815b 100644 --- a/messages/ru/auditLogs.json +++ b/messages/ru/auditLogs.json @@ -72,7 +72,7 @@ }, "model_price": { "bulk_upload": "Массовая загрузка цен моделей", - "sync_litellm": "Синхронизация цен моделей LiteLLM", + "sync_litellm": "Синхронизация облачной таблицы цен", "upsert": "Обновление цены модели", "delete": "Удаление цены модели" } diff --git a/messages/ru/settings/prices.json b/messages/ru/settings/prices.json index 69a438b79..1835b89e6 100644 --- a/messages/ru/settings/prices.json +++ b/messages/ru/settings/prices.json @@ -9,30 +9,13 @@ "filters": { "all": "Все", "local": "Локальные", - "anthropic": "Anthropic", - "openai": "OpenAI", - "vertex": "Vertex", - "deepseek": "DeepSeek", - "mistral": "Mistral", - "meta": "Meta", - "cohere": "Cohere", - "xai": "xAI", - "groq": "Groq", - "bedrock": "Bedrock", - "azure": "Azure", - "together": "Together", - "nvidia": "NVIDIA", - "zhipuai": "Zhipu", - "volcengine": "Volcengine", - "minimax": "MiniMax", - "qwen": "Qwen", - "fireworks": "Fireworks", - "ollama": "Ollama", - "openrouter": "OpenRouter" + "moreVendors": "Больше вендоров" }, "badges": { "local": "Локальная", - "multi": "Мульти" + "multi": "Мульти", + "official": "Официальная цена", + "multiWithCount": "{count} источников" }, "capabilities": { "assistantPrefill": "Предзаполнение ассистента", @@ -64,25 +47,25 @@ }, "conflict": { "title": "Выберите элементы для перезаписи", - "description": "Следующие модели имеют ручные цены. Отмеченные будут перезаписаны ценами LiteLLM, неотмеченные останутся без изменений", + "description": "Для следующих моделей заданы ручные цены. Отметьте те, которые нужно перезаписать облачными ценами; неотмеченные останутся без изменений", "searchPlaceholder": "Поиск моделей...", "table": { "modelName": "Модель", "manualPrice": "Ручная цена", - "litellmPrice": "Цена LiteLLM", - "action": "Действие" + "action": "Действие", + "cloudPrice": "Облачная цена" }, "viewDiff": "Показать различия", "diffTitle": "Различия цен", "diff": { "field": "Поле", "manual": "Ручное", - "litellm": "LiteLLM", "inputPrice": "Цена ввода", "outputPrice": "Цена вывода", "imagePrice": "Цена изображения", "provider": "Поставщик", - "mode": "Тип" + "mode": "Тип", + "cloud": "Облако" }, "pagination": { "showing": "Показано {from}-{to} из {total}" @@ -148,7 +131,7 @@ "updateFailed": "Ошибка обновления", "systemHasBuiltIn": "Система имеет встроенный прайс-лист", "manualDownload": "Вы также можете скачать вручную", - "latestPriceTable": "облачный прайс-лист", + "latestPriceTable": "облачную таблицу цен (models.json)", "andUploadViaButton": ", и загрузить через кнопку выше", "cloudModelCountLoading": "Загрузка количества моделей из облака...", "cloudModelCountFailed": "Не удалось загрузить количество моделей из облака", @@ -218,7 +201,7 @@ "fields": { "mode": "Режим", "displayName": "Отображаемое имя", - "litellmProvider": "Провайдер LiteLLM", + "litellmProvider": "Провайдер LiteLLM (устар.)", "selectedPricingProvider": "Выбранный провайдер цены", "selectedPricingSourceModel": "Выбранная исходная модель", "selectedPricingResolution": "Способ разрешения", @@ -231,7 +214,12 @@ "inputCostPerRequest": "Цена за запрос", "outputCostPerImage": "Цена за изображение", "inputCostPerSecond": "Цена за секунду ввода", - "fileSearchCostPer1kCalls": "Цена файлового поиска за 1k вызовов" + "fileSearchCostPer1kCalls": "Цена файлового поиска за 1k вызовов", + "vendor": "Вендор", + "slug": "Идентификатор модели", + "officialPricingProvider": "Официальный поставщик цен", + "modelFamily": "Семейство моделей", + "knowledgeCutoff": "Дата знаний" } }, "toast": { @@ -251,6 +239,7 @@ "pinSuccess": "Цена {provider} закреплена как локальная цена модели", "pinFailed": "Не удалось закрепить цену провайдера", "pinned": "Закреплено", - "priority": "Цена быстрого режима" + "priority": "Цена быстрого режима", + "official": "Официальный" } } diff --git a/messages/zh-CN/auditLogs.json b/messages/zh-CN/auditLogs.json index 8fad548e4..d850d80d7 100644 --- a/messages/zh-CN/auditLogs.json +++ b/messages/zh-CN/auditLogs.json @@ -72,7 +72,7 @@ }, "model_price": { "bulk_upload": "批量上传模型价格", - "sync_litellm": "同步 LiteLLM 模型价格", + "sync_litellm": "同步云端价格表", "upsert": "更新模型价格", "delete": "删除模型价格" } diff --git a/messages/zh-CN/settings/prices.json b/messages/zh-CN/settings/prices.json index 9171804e4..9f0c54abb 100644 --- a/messages/zh-CN/settings/prices.json +++ b/messages/zh-CN/settings/prices.json @@ -9,30 +9,13 @@ "filters": { "all": "全部", "local": "本地", - "anthropic": "Anthropic", - "openai": "OpenAI", - "vertex": "Vertex", - "deepseek": "DeepSeek", - "mistral": "Mistral", - "meta": "Meta", - "cohere": "Cohere", - "xai": "xAI", - "groq": "Groq", - "bedrock": "Bedrock", - "azure": "Azure", - "together": "Together", - "nvidia": "NVIDIA", - "zhipuai": "智谱", - "volcengine": "火山引擎", - "minimax": "MiniMax", - "qwen": "Qwen", - "fireworks": "Fireworks", - "ollama": "Ollama", - "openrouter": "OpenRouter" + "moreVendors": "更多厂商" }, "badges": { "local": "本地", - "multi": "多供应商" + "multi": "多供应商", + "official": "官方价", + "multiWithCount": "{count} 个来源" }, "capabilities": { "assistantPrefill": "助手预填充", @@ -64,25 +47,25 @@ }, "conflict": { "title": "选择要覆盖的冲突项", - "description": "以下模型存在手动维护的价格,勾选后将用 LiteLLM 价格覆盖,未勾选的保持本地不变", + "description": "以下模型存在手动维护的价格。勾选需要用云端价格覆盖的模型,未勾选的将保持不变", "searchPlaceholder": "搜索模型...", "table": { "modelName": "模型", "manualPrice": "手动价格", - "litellmPrice": "LiteLLM 价格", - "action": "操作" + "action": "操作", + "cloudPrice": "云端价格" }, "viewDiff": "查看差异", "diffTitle": "价格差异对比", "diff": { "field": "字段", "manual": "手动", - "litellm": "LiteLLM", "inputPrice": "输入价格", "outputPrice": "输出价格", "imagePrice": "图片价格", "provider": "供应商", - "mode": "类型" + "mode": "类型", + "cloud": "云端" }, "pagination": { "showing": "显示 {from}-{to} 条,共 {total} 条" @@ -148,7 +131,7 @@ "updateFailed": "更新失败", "systemHasBuiltIn": "系统已内置价格表", "manualDownload": "你也可以手动下载", - "latestPriceTable": "云端价格表", + "latestPriceTable": "云端价格表(models.json)", "andUploadViaButton": ",并通过上方按钮上传", "cloudModelCountLoading": "云端模型数量加载中...", "cloudModelCountFailed": "云端模型数量加载失败", @@ -218,7 +201,7 @@ "fields": { "mode": "模式", "displayName": "展示名称", - "litellmProvider": "LiteLLM 供应商", + "litellmProvider": "LiteLLM 供应商(旧版)", "selectedPricingProvider": "选中价格提供方", "selectedPricingSourceModel": "选中源模型", "selectedPricingResolution": "选中解析方式", @@ -231,7 +214,12 @@ "inputCostPerRequest": "按次输入价格", "outputCostPerImage": "按图输出价格", "inputCostPerSecond": "按秒输入价格", - "fileSearchCostPer1kCalls": "每千次文件检索价格" + "fileSearchCostPer1kCalls": "每千次文件检索价格", + "vendor": "厂商", + "slug": "模型标识", + "officialPricingProvider": "官方价格提供方", + "modelFamily": "模型系列", + "knowledgeCutoff": "知识截止" } }, "toast": { @@ -251,6 +239,7 @@ "pinSuccess": "已将 {provider} 价格固化为本地模型价格", "pinFailed": "固化供应商价格失败", "pinned": "已固化", - "priority": "快速模式价格" + "priority": "快速模式价格", + "official": "官方" } } diff --git a/messages/zh-TW/auditLogs.json b/messages/zh-TW/auditLogs.json index c63323140..139a2316f 100644 --- a/messages/zh-TW/auditLogs.json +++ b/messages/zh-TW/auditLogs.json @@ -72,7 +72,7 @@ }, "model_price": { "bulk_upload": "批次上傳模型價格", - "sync_litellm": "同步 LiteLLM 模型價格", + "sync_litellm": "同步雲端價格表", "upsert": "更新模型價格", "delete": "刪除模型價格" } diff --git a/messages/zh-TW/settings/prices.json b/messages/zh-TW/settings/prices.json index 73e689ab9..f23fcd493 100644 --- a/messages/zh-TW/settings/prices.json +++ b/messages/zh-TW/settings/prices.json @@ -9,30 +9,13 @@ "filters": { "all": "所有", "local": "本機", - "anthropic": "Anthropic", - "openai": "OpenAI", - "vertex": "Vertex", - "deepseek": "DeepSeek", - "mistral": "Mistral", - "meta": "Meta", - "cohere": "Cohere", - "xai": "xAI", - "groq": "Groq", - "bedrock": "Bedrock", - "azure": "Azure", - "together": "Together", - "nvidia": "NVIDIA", - "zhipuai": "智譜", - "volcengine": "火山引擎", - "minimax": "MiniMax", - "qwen": "Qwen", - "fireworks": "Fireworks", - "ollama": "Ollama", - "openrouter": "OpenRouter" + "moreVendors": "更多廠商" }, "badges": { "local": "本機", - "multi": "多供應商" + "multi": "多供應商", + "official": "官方價", + "multiWithCount": "{count} 個來源" }, "capabilities": { "assistantPrefill": "助手預填充", @@ -64,25 +47,25 @@ }, "conflict": { "title": "選擇要覆蓋的衝突項", - "description": "以下模型存在手動維護的價格,勾選後將用 LiteLLM 價格覆蓋,未勾選的保持本地不變", + "description": "以下模型存在手動維護的價格。勾選需要用雲端價格覆蓋的模型,未勾選的將保持不變", "searchPlaceholder": "搜尋模型...", "table": { "modelName": "模型名稱", "manualPrice": "手動價格", - "litellmPrice": "LiteLLM 價格", - "action": "動作" + "action": "動作", + "cloudPrice": "雲端價格" }, "viewDiff": "查看差異", "diffTitle": "價格差異對比", "diff": { "field": "欄位", "manual": "手動", - "litellm": "LiteLLM", "inputPrice": "輸入價格", "outputPrice": "輸出價格", "imagePrice": "圖片價格", "provider": "供應商", - "mode": "類型" + "mode": "類型", + "cloud": "雲端" }, "pagination": { "showing": "顯示 {from}-{to} 條,共 {total} 條" @@ -148,7 +131,7 @@ "updateFailed": "更新失敗", "systemHasBuiltIn": "系統已內置價格表", "manualDownload": "你也可以手動下載", - "latestPriceTable": "雲端價格表", + "latestPriceTable": "雲端價格表(models.json)", "andUploadViaButton": ",並透過上方按鈕上傳", "cloudModelCountLoading": "雲端模型數量載入中...", "cloudModelCountFailed": "雲端模型數量載入失敗", @@ -218,7 +201,7 @@ "fields": { "mode": "模式", "displayName": "顯示名稱", - "litellmProvider": "LiteLLM 供應商", + "litellmProvider": "LiteLLM 供應商(舊版)", "selectedPricingProvider": "選定價格提供方", "selectedPricingSourceModel": "選定來源模型", "selectedPricingResolution": "選定解析方式", @@ -231,7 +214,12 @@ "inputCostPerRequest": "按次輸入價格", "outputCostPerImage": "按圖輸出價格", "inputCostPerSecond": "按秒輸入價格", - "fileSearchCostPer1kCalls": "每千次檔案檢索價格" + "fileSearchCostPer1kCalls": "每千次檔案檢索價格", + "vendor": "廠商", + "slug": "模型標識", + "officialPricingProvider": "官方價格提供方", + "modelFamily": "模型系列", + "knowledgeCutoff": "知識截止" } }, "toast": { @@ -251,6 +239,7 @@ "pinSuccess": "已將 {provider} 價格固化為本地模型價格", "pinFailed": "固化供應商價格失敗", "pinned": "已固化", - "priority": "快速模式價格" + "priority": "快速模式價格", + "official": "官方" } } diff --git a/src/actions/model-prices.ts b/src/actions/model-prices.ts index 30d43c7b0..608990567 100644 --- a/src/actions/model-prices.ts +++ b/src/actions/model-prices.ts @@ -4,10 +4,13 @@ import { revalidatePath } from "next/cache"; import { emitActionAudit } from "@/lib/audit/emit"; import { getSession } from "@/lib/auth"; import { logger } from "@/lib/logger"; +import { parseCloudPriceTableToml } from "@/lib/price-sync/cloud-price-table"; import { - fetchCloudPriceTableToml, - parseCloudPriceTableToml, -} from "@/lib/price-sync/cloud-price-table"; + applyConvertedCloudPriceTable, + loadConvertedCloudPriceTable, +} from "@/lib/price-sync/cloud-price-updater"; +import { convertCptTable } from "@/lib/price-sync/cpt-convert"; +import { isCptTableLike, parseCptTableValue } from "@/lib/price-sync/cpt-schema"; import { isPriceLikeFieldPath } from "@/lib/utils/model-price-fields"; import { createModelPrice, @@ -102,14 +105,14 @@ function buildManualPriceDataFromProviderPricing( * 用于系统初始化、云端自动同步和 Web UI 上传 * @param jsonContent - 价格表 JSON 内容 * @param overwriteManual - 可选,要覆盖的手动添加模型名称列表 - * @param source - 写入记录的来源。云端/自动同步为 'litellm'(默认); + * @param source - 写入记录的来源。云端/自动同步为 'cloud'(默认); * 用户在本地显式上传的价格表为 'manual',使其遵循“本地优先”原则、 * 不被后续云端自动同步覆盖。 */ export async function processPriceTableInternal( jsonContent: string, overwriteManual?: string[], - source: ModelPriceSource = "litellm" + source: ModelPriceSource = "cloud" ): Promise> { try { // 解析JSON内容 @@ -180,11 +183,11 @@ export async function processPriceTableInternal( continue; } - // 本地优先:仅当本次写入来自云端/自动同步(source='litellm')时, + // 本地优先:仅当本次写入来自云端/自动同步(source 非 'manual')时, // 才跳过用户手动维护的模型,除非显式列入覆盖列表。 // 用户显式上传(source='manual')属于权威导入,不受此保护跳过,可正常覆盖。 const isManualPrice = manualPrices.has(modelName); - if (source === "litellm" && isManualPrice && !overwriteSet.has(modelName)) { + if (source !== "manual" && isManualPrice && !overwriteSet.has(modelName)) { // 跳过手动添加的模型,记录到 skippedConflicts result.skippedConflicts?.push(modelName); result.unchanged.push(modelName); @@ -238,8 +241,9 @@ export async function processPriceTableInternal( * 上传并更新模型价格表(Web UI 入口,包含权限检查) * * 支持格式: - * - JSON:PriceTableJson(内部入库格式) - * - TOML:云端价格表格式(会提取 models 表后再入库) + * - JSON(CPT v1):云端价格表 models.json(schema=cchp.pricing-table/v1),转换后入库 + * - JSON(Legacy):PriceTableJson(内部入库格式) + * - TOML:旧版云端价格表格式(会提取 models 表后再入库) * @param overwriteManual - 可选,要覆盖的手动添加模型名称列表 */ export async function uploadPriceTable( @@ -252,10 +256,24 @@ export async function uploadPriceTable( return { ok: false, error: "无权限执行此操作" }; } - // 先尝试 JSON;失败则按 TOML 解析(用于云端价格表文件直接上传) + // 先尝试 JSON(CPT v1 云端表或内部格式);失败则按 TOML 解析(旧版价格表文件直接上传) let jsonContent = content; try { - JSON.parse(content); + const parsedJson = JSON.parse(content) as unknown; + if (isCptTableLike(parsedJson)) { + const cptResult = parseCptTableValue(parsedJson); + if (!cptResult.ok) { + emitActionAudit({ + category: "model_price", + action: "model_price.bulk_upload", + targetType: "model_price", + success: false, + errorMessage: cptResult.error, + }); + return { ok: false, error: cptResult.error }; + } + jsonContent = JSON.stringify(convertCptTable(cptResult.data).models); + } } catch { const parseResult = parseCloudPriceTableToml(content); if (!parseResult.ok) { @@ -324,6 +342,8 @@ export async function getModelPrices(): Promise { export interface AvailableModelCatalogItem { modelName: string; + /** 云端 vendor slug(新价格表);旧数据回退 litellm_provider */ + vendor: string | null; litellmProvider: string | null; updatedAt: string; } @@ -357,6 +377,7 @@ export async function getAvailableModelCatalog(options?: { }) .map((price) => ({ modelName: price.modelName, + vendor: typeof price.priceData.vendor === "string" ? price.priceData.vendor : null, litellmProvider: typeof price.priceData.litellm_provider === "string" ? price.priceData.litellm_provider @@ -437,7 +458,7 @@ export async function getAvailableModelsByProviderType(): Promise { */ /** - * 检查 LiteLLM 同步是否会产生冲突 + * 检查云端价格表同步是否会产生冲突 * @returns 冲突检查结果,包含是否有冲突以及冲突列表 */ export async function checkLiteLLMSyncConflicts(): Promise> { @@ -448,35 +469,27 @@ export async function checkLiteLLMSyncConflicts(): Promise = applied.ok + ? { ok: true, data: applied.data } + : { ok: false, error: applied.error }; if (result.ok) { logger.info("[PriceSync] Cloud price sync completed", { @@ -887,7 +889,9 @@ export async function pinModelPricingProviderAsManual(input: { return { ok: false, error: "价格提供商不能为空" }; } - const latestCloudPrice = await findLatestPriceByModelAndSource(modelName, "litellm"); + const latestCloudPrice = + (await findLatestPriceByModelAndSource(modelName, "cloud")) ?? + (await findLatestPriceByModelAndSource(modelName, "litellm")); if (!latestCloudPrice) { return { ok: false, error: "未找到云端模型价格" }; } diff --git a/src/app/[locale]/settings/prices/_components/model-price-details-dialog.tsx b/src/app/[locale]/settings/prices/_components/model-price-details-dialog.tsx index f8e797611..5d11977f9 100644 --- a/src/app/[locale]/settings/prices/_components/model-price-details-dialog.tsx +++ b/src/app/[locale]/settings/prices/_components/model-price-details-dialog.tsx @@ -28,6 +28,11 @@ const FIELD_LABEL_KEYS: Record = { mode: "mode", display_name: "displayName", litellm_provider: "litellmProvider", + vendor: "vendor", + slug: "slug", + official_pricing_provider: "officialPricingProvider", + model_family: "modelFamily", + knowledge_cutoff: "knowledgeCutoff", selected_pricing_provider: "selectedPricingProvider", selected_pricing_source_model: "selectedPricingSourceModel", selected_pricing_resolution: "selectedPricingResolution", diff --git a/src/app/[locale]/settings/prices/_components/price-list.tsx b/src/app/[locale]/settings/prices/_components/price-list.tsx index 071f51a9b..9ecad16bd 100644 --- a/src/app/[locale]/settings/prices/_components/price-list.tsx +++ b/src/app/[locale]/settings/prices/_components/price-list.tsx @@ -24,6 +24,7 @@ import { import { useLocale, useTimeZone, useTranslations } from "next-intl"; import { useCallback, useEffect, useRef, useState } from "react"; import { toast } from "sonner"; +import { ModelVendorIcon } from "@/components/customs/model-vendor-icon"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { @@ -42,7 +43,7 @@ import { } from "@/components/ui/select"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { useDebounce } from "@/lib/hooks/use-debounce"; -import { PRICE_FILTER_VENDORS } from "@/lib/model-vendor-icons"; +import type { CloudVendorSummary } from "@/lib/price-sync/cpt-convert"; import { copyToClipboard } from "@/lib/utils/clipboard"; import { resolvePricingForModelRecords } from "@/lib/utils/pricing-resolution"; import type { ModelPrice, ModelPriceSource } from "@/types/model-price"; @@ -58,9 +59,12 @@ interface PriceListProps { initialPageSize: number; initialSearchTerm: string; initialSourceFilter: ModelPriceSource | ""; - initialLitellmProviderFilter: string; + initialVendorFilter: string; } +// 快捷筛选按钮最多展示的 vendor 数,其余进入下拉 +const QUICK_VENDOR_BUTTON_COUNT = 12; + /** * 价格列表组件(支持分页) */ @@ -71,7 +75,7 @@ export function PriceList({ initialPageSize, initialSearchTerm, initialSourceFilter, - initialLitellmProviderFilter, + initialVendorFilter, }: PriceListProps) { const t = useTranslations("settings.prices"); const tCommon = useTranslations("common"); @@ -79,7 +83,8 @@ export function PriceList({ const timeZone = useTimeZone() ?? "UTC"; const [searchTerm, setSearchTerm] = useState(initialSearchTerm); const [sourceFilter, setSourceFilter] = useState(initialSourceFilter); - const [litellmProviderFilter, setLitellmProviderFilter] = useState(initialLitellmProviderFilter); + const [vendorFilter, setVendorFilter] = useState(initialVendorFilter); + const [vendors, setVendors] = useState([]); const [prices, setPrices] = useState(initialPrices); const [total, setTotal] = useState(initialTotal); const [page, setPage] = useState(initialPage); @@ -101,7 +106,7 @@ export function PriceList({ newPage: number, newPageSize: number, newSourceFilter: ModelPriceSource | "", - newLitellmProviderFilter: string + newVendorFilter: string ) => { const url = new URL(window.location.href); if (newSearchTerm) { @@ -128,11 +133,12 @@ export function PriceList({ url.searchParams.delete("source"); } - if (newLitellmProviderFilter) { - url.searchParams.set("litellmProvider", newLitellmProviderFilter); + if (newVendorFilter) { + url.searchParams.set("vendor", newVendorFilter); } else { - url.searchParams.delete("litellmProvider"); + url.searchParams.delete("vendor"); } + url.searchParams.delete("litellmProvider"); window.history.replaceState({}, "", url.toString()); }, [] @@ -145,7 +151,7 @@ export function PriceList({ newPageSize: number, newSearchTerm: string, newSourceFilter: ModelPriceSource | "", - newLitellmProviderFilter: string + newVendorFilter: string ) => { setIsLoading(true); try { @@ -157,8 +163,8 @@ export function PriceList({ if (newSourceFilter) { url.searchParams.set("source", newSourceFilter); } - if (newLitellmProviderFilter) { - url.searchParams.set("litellmProvider", newLitellmProviderFilter); + if (newVendorFilter) { + url.searchParams.set("vendor", newVendorFilter); } const response = await fetch(url.toString()); @@ -185,16 +191,16 @@ export function PriceList({ const forcedPage = pendingRefreshPage.current; if (typeof forcedPage === "number") { pendingRefreshPage.current = null; - fetchPrices(forcedPage, pageSize, debouncedSearchTerm, sourceFilter, litellmProviderFilter); + fetchPrices(forcedPage, pageSize, debouncedSearchTerm, sourceFilter, vendorFilter); return; } - fetchPrices(page, pageSize, debouncedSearchTerm, sourceFilter, litellmProviderFilter); + fetchPrices(page, pageSize, debouncedSearchTerm, sourceFilter, vendorFilter); }; window.addEventListener("price-data-updated", handlePriceUpdate); return () => window.removeEventListener("price-data-updated", handlePriceUpdate); - }, [page, pageSize, debouncedSearchTerm, fetchPrices, sourceFilter, litellmProviderFilter]); + }, [page, pageSize, debouncedSearchTerm, fetchPrices, sourceFilter, vendorFilter]); // 当防抖后的搜索词变化时,触发搜索(重置到第一页) useEffect(() => { @@ -205,9 +211,9 @@ export function PriceList({ const newPage = 1; // 搜索时重置到第一页 setPage(newPage); - updateURL(debouncedSearchTerm, newPage, pageSize, sourceFilter, litellmProviderFilter); - fetchPrices(newPage, pageSize, debouncedSearchTerm, sourceFilter, litellmProviderFilter); - }, [debouncedSearchTerm, fetchPrices, litellmProviderFilter, pageSize, sourceFilter, updateURL]); + updateURL(debouncedSearchTerm, newPage, pageSize, sourceFilter, vendorFilter); + fetchPrices(newPage, pageSize, debouncedSearchTerm, sourceFilter, vendorFilter); + }, [debouncedSearchTerm, fetchPrices, vendorFilter, pageSize, sourceFilter, updateURL]); // 搜索输入处理(只更新状态,不触发请求) const handleSearchChange = (value: string) => { @@ -219,16 +225,16 @@ export function PriceList({ const newPage = Math.max(1, Math.min(page, Math.ceil(total / newPageSize))); setPageSize(newPageSize); setPage(newPage); - updateURL(debouncedSearchTerm, newPage, newPageSize, sourceFilter, litellmProviderFilter); - fetchPrices(newPage, newPageSize, debouncedSearchTerm, sourceFilter, litellmProviderFilter); + updateURL(debouncedSearchTerm, newPage, newPageSize, sourceFilter, vendorFilter); + fetchPrices(newPage, newPageSize, debouncedSearchTerm, sourceFilter, vendorFilter); }; // 页面跳转处理 const handlePageChange = (newPage: number) => { if (newPage < 1 || newPage > totalPages) return; setPage(newPage); - updateURL(debouncedSearchTerm, newPage, pageSize, sourceFilter, litellmProviderFilter); - fetchPrices(newPage, pageSize, debouncedSearchTerm, sourceFilter, litellmProviderFilter); + updateURL(debouncedSearchTerm, newPage, pageSize, sourceFilter, vendorFilter); + fetchPrices(newPage, pageSize, debouncedSearchTerm, sourceFilter, vendorFilter); }; // 移除客户端过滤逻辑(现在由后端处理) @@ -322,27 +328,51 @@ export function PriceList({ ]; const applyFilters = useCallback( - (next: { source: ModelPriceSource | ""; litellmProvider: string }) => { + (next: { source: ModelPriceSource | ""; vendor: string }) => { setSourceFilter(next.source); - setLitellmProviderFilter(next.litellmProvider); + setVendorFilter(next.vendor); const newPage = 1; setPage(newPage); - updateURL(debouncedSearchTerm, newPage, pageSize, next.source, next.litellmProvider); - fetchPrices(newPage, pageSize, debouncedSearchTerm, next.source, next.litellmProvider); + updateURL(debouncedSearchTerm, newPage, pageSize, next.source, next.vendor); + fetchPrices(newPage, pageSize, debouncedSearchTerm, next.source, next.vendor); }, [debouncedSearchTerm, fetchPrices, pageSize, updateURL] ); + // 云端 vendor 汇总(筛选按钮数据源) + useEffect(() => { + let cancelled = false; + const loadVendors = async () => { + try { + const response = await fetch("/api/prices/vendors", { cache: "no-store" }); + const payload = await response.json(); + if (!cancelled && payload?.ok && Array.isArray(payload.data?.vendors)) { + setVendors(payload.data.vendors as CloudVendorSummary[]); + } + } catch (error) { + console.error("获取云端 vendor 列表失败:", error); + } + }; + loadVendors(); + return () => { + cancelled = true; + }; + }, []); + + const quickVendors = vendors.slice(0, QUICK_VENDOR_BUTTON_COUNT); + const moreVendors = vendors.slice(QUICK_VENDOR_BUTTON_COUNT); + const activeVendorInMore = moreVendors.some((item) => item.vendor === vendorFilter); + return (
{/* 快捷筛选 */}
@@ -354,7 +384,7 @@ export function PriceList({ onClick={() => applyFilters({ source: sourceFilter === "manual" ? "" : "manual", - litellmProvider: "", + vendor: "", }) } > @@ -362,23 +392,65 @@ export function PriceList({ {t("filters.local")} - {PRICE_FILTER_VENDORS.map(({ i18nKey, litellmProvider, icon: Icon }) => ( + {quickVendors.map((item) => ( ))} + + {moreVendors.length > 0 ? ( + + ) : null}
{/* 搜索和页面大小控制 */} @@ -465,6 +537,21 @@ export function PriceList({ ? displayPriceData.selected_pricing_provider : null); + const vendorSlug = + typeof price.priceData.vendor === "string" ? price.priceData.vendor : null; + const isOfficialPricing = + displayPricing?.source === "cloud_official" || + displayPricing?.pricingNode?.official === true; + const pricingProviderCount = price.priceData.pricing + ? Object.keys(price.priceData.pricing).length + : 0; + // 带斜杠的模型 ID 分段展示:弱化前缀,突出最后一段 + const lastSlashIndex = price.modelName.lastIndexOf("/"); + const modelIdPrefix = + lastSlashIndex >= 0 ? price.modelName.slice(0, lastSlashIndex + 1) : ""; + const modelIdBase = + lastSlashIndex >= 0 ? price.modelName.slice(lastSlashIndex + 1) : price.modelName; + return (
+ {price.priceData.display_name?.trim() || price.modelName} - {price.priceData.litellm_provider ? ( + {vendorSlug ? ( + + {vendorSlug} + + ) : price.priceData.litellm_provider ? ( {price.priceData.litellm_provider} ) : null} {displayPricingProviderKey && + displayPricingProviderKey !== vendorSlug && displayPricingProviderKey !== price.priceData.litellm_provider ? ( {displayPricingProviderKey} ) : null} - {price.priceData.pricing && - Object.keys(price.priceData.pricing).length > 1 ? ( - {t("badges.multi")} + {isOfficialPricing ? ( + + {t("badges.official")} + + ) : null} + {pricingProviderCount > 1 ? ( + + {t("badges.multiWithCount", { count: pricingProviderCount })} + ) : null} {price.source === "manual" && ( {t("badges.local")} @@ -500,10 +609,13 @@ export function PriceList({ {t("table.copyModelId")} @@ -679,7 +791,7 @@ export function PriceList({ targetPage, pageSize, sourceFilter, - litellmProviderFilter + vendorFilter ); } }} diff --git a/src/app/[locale]/settings/prices/_components/provider-pricing-dialog.tsx b/src/app/[locale]/settings/prices/_components/provider-pricing-dialog.tsx index 2fc817fe4..9b40ffa32 100644 --- a/src/app/[locale]/settings/prices/_components/provider-pricing-dialog.tsx +++ b/src/app/[locale]/settings/prices/_components/provider-pricing-dialog.tsx @@ -4,6 +4,7 @@ import { ArrowRightLeft, Loader2, Pin } from "lucide-react"; import { useTranslations } from "next-intl"; import { useMemo, useState } from "react"; import { toast } from "sonner"; +import { ModelVendorIcon } from "@/components/customs/model-vendor-icon"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { @@ -50,11 +51,17 @@ export function ProviderPricingDialog({ price, trigger, onSuccess }: ProviderPri return [] as Array<[string, Record]>; } + // 官方报价排在最前,其余按 slug 字典序 return Object.entries(pricing) .filter((entry): entry is [string, Record] => { return !!entry[1] && typeof entry[1] === "object" && !Array.isArray(entry[1]); }) - .sort((a, b) => a[0].localeCompare(b[0])); + .sort((a, b) => { + const officialA = a[1].official === true ? 0 : 1; + const officialB = b[1].official === true ? 0 : 1; + if (officialA !== officialB) return officialA - officialB; + return a[0].localeCompare(b[0]); + }); }, [price.priceData.pricing]); const handlePin = async (pricingProviderKey: string) => { @@ -111,9 +118,25 @@ export function ProviderPricingDialog({ price, trigger, onSuccess }: ProviderPri >
+ {providerKey} + {providerPricing.official === true ? ( + + {t("providerPricing.official")} + + ) : null} + {typeof providerPricing.provider_model_id === "string" && + providerPricing.provider_model_id !== price.modelName ? ( + + {providerPricing.provider_model_id} + + ) : null} {price.priceData.selected_pricing_provider === providerKey ? ( {t("providerPricing.pinned")} ) : null} diff --git a/src/app/[locale]/settings/prices/_components/sync-conflict-dialog.tsx b/src/app/[locale]/settings/prices/_components/sync-conflict-dialog.tsx index ecda7790f..ebcba5345 100644 --- a/src/app/[locale]/settings/prices/_components/sync-conflict-dialog.tsx +++ b/src/app/[locale]/settings/prices/_components/sync-conflict-dialog.tsx @@ -57,10 +57,10 @@ function formatPrice(value?: number): string { */ function PriceDiffPopover({ manualPrice, - litellmPrice, + cloudPrice, }: { manualPrice: ModelPriceData; - litellmPrice: ModelPriceData; + cloudPrice: ModelPriceData; }) { const t = useTranslations("settings.prices.conflict"); @@ -68,68 +68,68 @@ function PriceDiffPopover({ const items: Array<{ field: string; manual: string; - litellm: string; + cloud: string; changed: boolean; }> = []; // 输入价格 const manualInput = formatPrice(manualPrice.input_cost_per_token); - const litellmInput = formatPrice(litellmPrice.input_cost_per_token); + const cloudInput = formatPrice(cloudPrice.input_cost_per_token); items.push({ field: t("diff.inputPrice"), manual: manualInput, - litellm: litellmInput, - changed: manualInput !== litellmInput, + cloud: cloudInput, + changed: manualInput !== cloudInput, }); // 输出价格 const manualOutput = formatPrice(manualPrice.output_cost_per_token); - const litellmOutput = formatPrice(litellmPrice.output_cost_per_token); + const cloudOutput = formatPrice(cloudPrice.output_cost_per_token); items.push({ field: t("diff.outputPrice"), manual: manualOutput, - litellm: litellmOutput, - changed: manualOutput !== litellmOutput, + cloud: cloudOutput, + changed: manualOutput !== cloudOutput, }); // 图片价格(如果有) - if (manualPrice.output_cost_per_image || litellmPrice.output_cost_per_image) { + if (manualPrice.output_cost_per_image || cloudPrice.output_cost_per_image) { const manualImg = manualPrice.output_cost_per_image ? `$${manualPrice.output_cost_per_image}/img` : "-"; - const litellmImg = litellmPrice.output_cost_per_image - ? `$${litellmPrice.output_cost_per_image}/img` + const cloudImg = cloudPrice.output_cost_per_image + ? `$${cloudPrice.output_cost_per_image}/img` : "-"; items.push({ field: t("diff.imagePrice"), manual: manualImg, - litellm: litellmImg, - changed: manualImg !== litellmImg, + cloud: cloudImg, + changed: manualImg !== cloudImg, }); } // 供应商 - const manualProvider = manualPrice.litellm_provider || "-"; - const litellmProvider = litellmPrice.litellm_provider || "-"; + const manualProvider = manualPrice.vendor || manualPrice.litellm_provider || "-"; + const cloudVendor = cloudPrice.vendor || cloudPrice.litellm_provider || "-"; items.push({ field: t("diff.provider"), manual: manualProvider, - litellm: litellmProvider, - changed: manualProvider !== litellmProvider, + cloud: cloudVendor, + changed: manualProvider !== cloudVendor, }); // 类型 const manualMode = manualPrice.mode || "-"; - const litellmMode = litellmPrice.mode || "-"; + const cloudMode = cloudPrice.mode || "-"; items.push({ field: t("diff.mode"), manual: manualMode, - litellm: litellmMode, - changed: manualMode !== litellmMode, + cloud: cloudMode, + changed: manualMode !== cloudMode, }); return items; - }, [manualPrice, litellmPrice, t]); + }, [manualPrice, cloudPrice, t]); return ( @@ -147,7 +147,7 @@ function PriceDiffPopover({ {t("diff.field")} {t("diff.manual")} - {t("diff.litellm")} + {t("diff.cloud")} @@ -163,9 +163,9 @@ function PriceDiffPopover({ {diff.changed ? ( - {diff.litellm} + {diff.cloud} ) : ( - diff.litellm + diff.cloud )} @@ -291,7 +291,7 @@ export function SyncConflictDialog({ {t("table.modelName")} {t("table.manualPrice")} - {t("table.litellmPrice")} + {t("table.cloudPrice")} {t("table.action")} @@ -315,13 +315,13 @@ export function SyncConflictDialog({ - {formatPrice(conflict.litellmPrice.input_cost_per_token)} + {formatPrice(conflict.cloudPrice.input_cost_per_token)} diff --git a/src/app/[locale]/settings/prices/_components/upload-price-dialog.tsx b/src/app/[locale]/settings/prices/_components/upload-price-dialog.tsx index 7aaa066f8..20f683484 100644 --- a/src/app/[locale]/settings/prices/_components/upload-price-dialog.tsx +++ b/src/app/[locale]/settings/prices/_components/upload-price-dialog.tsx @@ -249,7 +249,7 @@ export function UploadPriceDialog({ • {t("dialog.manualDownload")}{" "} diff --git a/src/app/[locale]/settings/prices/page.tsx b/src/app/[locale]/settings/prices/page.tsx index ad6c4c2f1..553d6f080 100644 --- a/src/app/[locale]/settings/prices/page.tsx +++ b/src/app/[locale]/settings/prices/page.tsx @@ -18,7 +18,7 @@ type SettingsPricesSearchParams = { size?: string; search?: string; source?: string; - litellmProvider?: string; + vendor?: string; }; interface SettingsPricesPageProps { @@ -64,8 +64,10 @@ async function SettingsPricesContent({ const pageSize = parseInt(params.pageSize || params.size || "50", 10); const search = params.search?.trim() || undefined; const source = - params.source === "manual" || params.source === "litellm" ? params.source : undefined; - const litellmProvider = params.litellmProvider?.trim() || undefined; + params.source === "manual" || params.source === "cloud" || params.source === "litellm" + ? params.source + : undefined; + const vendor = params.vendor?.trim() || undefined; // 获取分页数据(搜索与过滤在 SQL 层面执行) const pricesResult = await getModelPricesPaginated({ @@ -73,7 +75,7 @@ async function SettingsPricesContent({ pageSize, search, source, - litellmProvider, + vendor, }); const isRequired = params.required === "true"; @@ -121,7 +123,7 @@ async function SettingsPricesContent({ initialPageSize={initialPageSize} initialSearchTerm={search ?? ""} initialSourceFilter={source ?? ""} - initialLitellmProviderFilter={litellmProvider ?? ""} + initialVendorFilter={vendor ?? ""} /> ); diff --git a/src/app/[locale]/settings/providers/_components/model-multi-select.tsx b/src/app/[locale]/settings/providers/_components/model-multi-select.tsx index bf27ca464..7276c621f 100644 --- a/src/app/[locale]/settings/providers/_components/model-multi-select.tsx +++ b/src/app/[locale]/settings/providers/_components/model-multi-select.tsx @@ -38,7 +38,7 @@ import { getAvailableModelCatalog, } from "@/lib/api-client/v1/actions/model-prices"; import { fetchUpstreamModels, getUnmaskedProviderKey } from "@/lib/api-client/v1/actions/providers"; -import { PRICE_FILTER_VENDORS } from "@/lib/model-vendor-icons"; +import { vendorDisplayName } from "@/lib/model-vendor/vendor-inference"; import { cn } from "@/lib/utils"; import type { ProviderType } from "@/types/provider"; @@ -93,12 +93,18 @@ function buildLocalOption(item: AvailableModelCatalogItem): ModelOption { function buildVirtualOption(modelName: string): ModelOption { return { modelName, + vendor: null, litellmProvider: null, updatedAt: "", key: getModelKey(modelName), }; } +/** 分组口径:云端 vendor 优先,旧数据回退 litellm_provider */ +function getModelGroupKey(model: AvailableModelCatalogItem): string | null { + return model.vendor ?? model.litellmProvider ?? null; +} + export function ModelMultiSelect({ providerType, selectedModels, @@ -124,42 +130,21 @@ export function ModelMultiSelect({ const requestIdRef = useRef(0); const providerOptions = useMemo(() => { - const knownProviders = new Map( - PRICE_FILTER_VENDORS.map((entry) => [entry.litellmProvider, entry.i18nKey]) - ); - const seen = new Set(); - const options: Array<{ value: string; label: string }> = []; - - for (const entry of PRICE_FILTER_VENDORS) { - if (availableModels.some((model) => model.litellmProvider === entry.litellmProvider)) { - seen.add(entry.litellmProvider); - options.push({ - value: entry.litellmProvider, - label: tPrices(`filters.${entry.i18nKey}`), - }); - } - } - - const unknownProviders = Array.from( - new Set( - availableModels - .map((model) => model.litellmProvider) - .filter((provider): provider is string => !!provider && !seen.has(provider)) - ) - ).sort((left, right) => left.localeCompare(right)); - - for (const provider of unknownProviders) { - seen.add(provider); - options.push({ - value: provider, - label: knownProviders.has(provider) - ? tPrices(`filters.${knownProviders.get(provider)}`) - : provider, - }); + // 按 vendor 聚合,数量多的在前;显示名走 vendorDisplayName 兜底 + const counts = new Map(); + for (const model of availableModels) { + const group = getModelGroupKey(model); + if (!group) continue; + counts.set(group, (counts.get(group) ?? 0) + 1); } - return options; - }, [availableModels, tPrices]); + return Array.from(counts.entries()) + .sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0])) + .map(([vendor]) => ({ + value: vendor, + label: vendorDisplayName(vendor), + })); + }, [availableModels]); const selectedKeySet = useMemo( () => new Set(selectedModels.map((model) => getModelKey(model))), @@ -199,7 +184,7 @@ export function ModelMultiSelect({ if (keyword && !model.modelName.toLowerCase().includes(keyword)) { return false; } - if (useProviderFilter && model.litellmProvider !== providerFilter) { + if (useProviderFilter && getModelGroupKey(model) !== providerFilter) { return false; } return true; @@ -249,6 +234,7 @@ export function ModelMultiSelect({ const upstreamModels = upstreamResult.data.models.map((modelName) => buildLocalOption({ modelName, + vendor: null, litellmProvider: null, updatedAt: "", }) @@ -526,12 +512,12 @@ export function ModelMultiSelect({
{model.modelName}
- {modelSource === "fallback" && model.litellmProvider ? ( + {modelSource === "fallback" && getModelGroupKey(model) ? (
{providerOptions.find( - (option) => option.value === model.litellmProvider - )?.label ?? model.litellmProvider} + (option) => option.value === getModelGroupKey(model) + )?.label ?? getModelGroupKey(model)}
) : null} diff --git a/src/app/api/prices/cloud-model-count/route.ts b/src/app/api/prices/cloud-model-count/route.ts index d2a5c1f3d..52460e859 100644 --- a/src/app/api/prices/cloud-model-count/route.ts +++ b/src/app/api/prices/cloud-model-count/route.ts @@ -1,9 +1,6 @@ import { NextResponse } from "next/server"; import { getSession } from "@/lib/auth"; -import { - fetchCloudPriceTableToml, - parseCloudPriceTableToml, -} from "@/lib/price-sync/cloud-price-table"; +import { fetchAndParseCloudPriceTable } from "@/lib/price-sync/cloud-price-table"; export async function GET() { // 权限检查:只有管理员可以访问 @@ -12,16 +9,11 @@ export async function GET() { return NextResponse.json({ ok: false, error: "无权限访问此资源" }, { status: 403 }); } - const tomlResult = await fetchCloudPriceTableToml(); - if (!tomlResult.ok) { - return NextResponse.json({ ok: false, error: tomlResult.error }, { status: 502 }); - } - - const parseResult = parseCloudPriceTableToml(tomlResult.data); + const parseResult = await fetchAndParseCloudPriceTable(); if (!parseResult.ok) { - return NextResponse.json({ ok: false, error: parseResult.error }, { status: 500 }); + return NextResponse.json({ ok: false, error: parseResult.error }, { status: 502 }); } - const count = Object.keys(parseResult.data.models).length; - return NextResponse.json({ ok: true, data: { count } }); + const count = parseResult.data.models.length; + return NextResponse.json({ ok: true, data: { count, version: parseResult.data.version } }); } diff --git a/src/app/api/prices/route.ts b/src/app/api/prices/route.ts index 304d22e28..50fa3476c 100644 --- a/src/app/api/prices/route.ts +++ b/src/app/api/prices/route.ts @@ -10,8 +10,9 @@ import type { PaginationParams } from "@/repository/model-price"; * - page: 页码 (默认: 1) * - pageSize: 每页大小 (默认: 50) * - search: 搜索关键词 (可选) - * - source: 价格来源过滤 (可选: manual|litellm) - * - litellmProvider: 云端提供商过滤 (可选,如 anthropic/openai/vertex_ai-language-models) + * - source: 价格来源过滤 (可选: manual|cloud|litellm) + * - vendor: 云端 vendor 过滤 (可选,如 anthropic/openai/google) + * - litellmProvider: 旧版云端提供商过滤 (可选,仅遗留数据可命中) */ export async function GET(request: NextRequest) { try { @@ -31,6 +32,7 @@ export async function GET(request: NextRequest) { ); const search = searchParams.get("search") || ""; const source = searchParams.get("source") || ""; + const vendor = searchParams.get("vendor") || ""; const litellmProvider = searchParams.get("litellmProvider") || ""; // 参数验证 @@ -42,7 +44,7 @@ export async function GET(request: NextRequest) { return NextResponse.json({ ok: false, error: "每页大小必须在1-200之间" }, { status: 400 }); } - if (source && source !== "manual" && source !== "litellm") { + if (source && source !== "manual" && source !== "cloud" && source !== "litellm") { return NextResponse.json({ ok: false, error: "source 参数无效" }, { status: 400 }); } @@ -52,6 +54,7 @@ export async function GET(request: NextRequest) { pageSize, search: search || undefined, // 传递搜索关键词给后端 source: source ? (source as PaginationParams["source"]) : undefined, + vendor: vendor || undefined, litellmProvider: litellmProvider || undefined, }; diff --git a/src/app/api/prices/vendors/route.ts b/src/app/api/prices/vendors/route.ts new file mode 100644 index 000000000..170d2a437 --- /dev/null +++ b/src/app/api/prices/vendors/route.ts @@ -0,0 +1,61 @@ +import { sql } from "drizzle-orm"; +import { NextResponse } from "next/server"; +import { db } from "@/drizzle/db"; +import { getSession } from "@/lib/auth"; +import { iconFileForVendor } from "@/lib/model-vendor/vendor-icon-files"; +import { vendorDisplayName } from "@/lib/model-vendor/vendor-inference"; +import type { CloudVendorSummary } from "@/lib/price-sync/cpt-convert"; +import { getCloudPricingCatalog } from "@/repository/cloud-pricing-catalog"; + +export const dynamic = "force-dynamic"; + +/** + * GET /api/prices/vendors + * + * 云端价格表 vendor 汇总(名称/图标/模型数),用于价格页供应商筛选。 + * 优先读取同步时落库的 cloud_pricing_catalog;目录缺失时降级为对 + * model_prices.price_data->>'vendor' 的去重统计。 + */ +export async function GET() { + const session = await getSession(); + if (!session || session.user.role !== "admin") { + return NextResponse.json({ ok: false, error: "无权限访问此资源" }, { status: 403 }); + } + + try { + const catalog = await getCloudPricingCatalog(); + if (catalog && Array.isArray(catalog.vendors) && catalog.vendors.length > 0) { + return NextResponse.json({ + ok: true, + data: { vendors: catalog.vendors, version: catalog.version }, + }); + } + + // 降级:目录未同步时从价格表行统计 + const result = await db.execute(sql` + SELECT price_data->>'vendor' AS vendor, COUNT(DISTINCT model_name) AS count + FROM model_prices + WHERE price_data->>'vendor' IS NOT NULL + GROUP BY price_data->>'vendor' + ORDER BY COUNT(DISTINCT model_name) DESC + `); + + const vendors: CloudVendorSummary[] = Array.from(result) + .map((row) => { + const vendor = String((row as { vendor?: unknown }).vendor ?? ""); + const icon = iconFileForVendor(vendor); + return { + vendor, + name: vendorDisplayName(vendor), + ...(icon ? { icon: icon.file, iconMono: icon.mono === true } : {}), + modelCount: Number((row as { count?: unknown }).count ?? 0), + }; + }) + .filter((item) => item.vendor); + + return NextResponse.json({ ok: true, data: { vendors, version: null } }); + } catch (error) { + console.error("获取云端 vendor 列表失败:", error); + return NextResponse.json({ ok: false, error: "服务器内部错误" }, { status: 500 }); + } +} diff --git a/src/app/api/v1/resources/model-prices/handlers.ts b/src/app/api/v1/resources/model-prices/handlers.ts index 37908cf68..3888ebac4 100644 --- a/src/app/api/v1/resources/model-prices/handlers.ts +++ b/src/app/api/v1/resources/model-prices/handlers.ts @@ -24,6 +24,7 @@ export async function listModelPrices(c: Context): Promise { pageSize: c.req.query("pageSize"), search: c.req.query("search"), source: c.req.query("source"), + vendor: c.req.query("vendor"), litellmProvider: c.req.query("litellmProvider"), }); if (!query.success) return fromZodError(query.error, new URL(c.req.url).pathname); diff --git a/src/components/customs/model-vendor-icon.tsx b/src/components/customs/model-vendor-icon.tsx index 717112fbc..392b972ab 100644 --- a/src/components/customs/model-vendor-icon.tsx +++ b/src/components/customs/model-vendor-icon.tsx @@ -1,18 +1,112 @@ "use client"; -import { getModelVendor } from "@/lib/model-vendor-icons"; +import { useState } from "react"; +import { accentColorOf, cloudModelIconUrl } from "@/lib/model-vendor/vendor-icon-files"; +import { inferVendorFromModelName, UNKNOWN_VENDOR } from "@/lib/model-vendor/vendor-inference"; +import { getVendorEntry, getVendorIconComponent } from "@/lib/model-vendor-icons"; interface ModelVendorIconProps { modelId: string; + /** 云端价格表下发的 vendor slug(优先于按模型名推断) */ + vendor?: string | null; + /** 云端价格表下发的图标文件基名(cch-plus.com/model-icons/),优先直接使用 */ + iconFile?: string | null; + /** 图标为单色时跟随暗色主题反色 */ + iconMono?: boolean; className?: string; } +function MonogramIcon({ seed, className }: { seed: string; className: string }) { + const initial = (/[a-z0-9]/i.exec(seed)?.[0] ?? "?").toUpperCase(); + const color = accentColorOf(seed); + return ( + + ); +} + +function RemoteVendorIcon({ + file, + mono, + fallbackSeed, + className, +}: { + file: string; + mono: boolean; + fallbackSeed: string; + className: string; +}) { + const [failed, setFailed] = useState(false); + if (failed) { + return ; + } + return ( + // biome-ignore lint/performance/noImgElement: 远程小尺寸 SVG,无需 next/image 优化管线 + setFailed(true)} + className={`select-none ${mono ? "dark:invert" : ""} ${className}`} + /> + ); +} + +/** + * 模型厂商图标。 + * 解析顺序:云端下发的 iconFile -> 本地打包组件(按 vendor)-> icon 映射表远程 SVG -> 字母 monogram。 + * vendor 未提供时按模型名正则推断(与云端价格表生成侧同一套规则)。 + */ export function ModelVendorIcon({ modelId, + vendor, + iconFile, + iconMono, className = "h-3.5 w-3.5 shrink-0", }: ModelVendorIconProps) { - const vendor = getModelVendor(modelId); - if (!vendor) return null; - const Icon = vendor.icon; - return ; + const resolvedVendor = vendor?.trim() || inferVendorFromModelName(modelId); + + if (iconFile?.trim()) { + // 云端价格表已解析好的图标:优先本地组件保证离线可用,否则直接用云端 SVG + const component = getVendorIconComponent(resolvedVendor); + if (component) { + const Icon = component; + return ; + } + return ( + + ); + } + + if (resolvedVendor === UNKNOWN_VENDOR) { + return ; + } + + const entry = getVendorEntry(resolvedVendor); + if (entry.icon) { + const Icon = entry.icon; + return ; + } + if (entry.iconFile) { + return ( + + ); + } + return ; } diff --git a/src/components/ui/__tests__/language-switcher.test.tsx b/src/components/ui/__tests__/language-switcher.test.tsx index 19af3c606..04771313a 100644 --- a/src/components/ui/__tests__/language-switcher.test.tsx +++ b/src/components/ui/__tests__/language-switcher.test.tsx @@ -131,7 +131,10 @@ describe("LanguageSwitcher", () => { }); test("keeps the pending refresh after remount when sessionStorage is blocked", () => { - const setItemSpy = vi.spyOn(window.sessionStorage, "setItem").mockImplementation(() => { + // happy-dom 的 sessionStorage 是 Proxy 且原型并非全局 Storage, + // 实例级 spy 会被当成存储项写入而不生效,需在实际原型上拦截 setItem + const storagePrototype = Object.getPrototypeOf(window.sessionStorage) as Storage; + const setItemSpy = vi.spyOn(storagePrototype, "setItem").mockImplementation(() => { throw new Error("blocked storage"); }); const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); diff --git a/src/drizzle/schema.ts b/src/drizzle/schema.ts index a96764de7..115da3dec 100644 --- a/src/drizzle/schema.ts +++ b/src/drizzle/schema.ts @@ -631,8 +631,8 @@ export const modelPrices = pgTable('model_prices', { id: serial('id').primaryKey(), modelName: varchar('model_name').notNull(), priceData: jsonb('price_data').notNull(), - // 价格来源: 'litellm' = 从 LiteLLM 同步, 'manual' = 手动添加 - source: varchar('source', { length: 20 }).notNull().default('litellm').$type<'litellm' | 'manual'>(), + // 价格来源: 'cloud' = 云端价格表同步, 'manual' = 手动添加, 'litellm' = 旧版云端同步遗留值 + source: varchar('source', { length: 20 }).notNull().default('cloud').$type<'cloud' | 'litellm' | 'manual'>(), createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(), updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(), }, (table) => ({ @@ -643,8 +643,35 @@ export const modelPrices = pgTable('model_prices', { modelPricesCreatedAtIdx: index('idx_model_prices_created_at').on(table.createdAt.desc()), // 按来源过滤的索引 modelPricesSourceIdx: index('idx_model_prices_source').on(table.source), + // 云端价格表 vendor 筛选(price_data->>'vendor') + modelPricesVendorIdx: index('idx_model_prices_vendor').using( + 'btree', + sql`((${table.priceData} ->> 'vendor'))` + ), + // 别名回退查询(price_data->'aliases' ? name) + modelPricesAliasesIdx: index('idx_model_prices_aliases').using( + 'gin', + sql`((${table.priceData} -> 'aliases'))` + ), })); +// 云端价格表目录元数据(providers 字典 / vendor 汇总 / 版本指纹),单行 upsert +export const cloudPricingCatalog = pgTable('cloud_pricing_catalog', { + id: serial('id').primaryKey(), + // 内容指纹(版本变化才有实质更新) + version: varchar('version', { length: 64 }).notNull(), + currency: varchar('currency', { length: 16 }).notNull().default('USD'), + // 云端快照刷新时间(表内 refreshed_at) + refreshedAt: timestamp('refreshed_at', { withTimezone: true }), + // provider slug -> { name, doc, icon, icon_mono } + providers: jsonb('providers').notNull(), + // vendor 汇总: [{ vendor, name, icon, iconMono, modelCount }] + vendors: jsonb('vendors').notNull(), + // 本次同步写入的云端模型数(用于一致性校验) + modelCount: integer('model_count').notNull().default(0), + syncedAt: timestamp('synced_at', { withTimezone: true }).defaultNow(), +}); + // Error Rules table export const errorRules = pgTable('error_rules', { id: serial('id').primaryKey(), diff --git a/src/lib/api-client/v1/openapi-types.gen.ts b/src/lib/api-client/v1/openapi-types.gen.ts index 5ae339f76..f3b5a10db 100644 --- a/src/lib/api-client/v1/openapi-types.gen.ts +++ b/src/lib/api-client/v1/openapi-types.gen.ts @@ -19385,8 +19385,10 @@ export interface operations { /** @description Optional model search text. */ search?: string; /** @description Optional source filter. */ - source?: "litellm" | "manual"; - /** @description Optional LiteLLM provider filter. */ + source?: "cloud" | "litellm" | "manual"; + /** @description Optional cloud vendor filter. */ + vendor?: string; + /** @description Legacy LiteLLM provider filter (matches pre-migration rows only). */ litellmProvider?: string; }; header?: never; @@ -19416,7 +19418,7 @@ export interface operations { * @description Price source. * @enum {string} */ - source: "litellm" | "manual"; + source: "cloud" | "litellm" | "manual"; /** * Format: date-time * @description Creation time. @@ -19612,7 +19614,9 @@ export interface operations { items: { /** @description Model name. */ modelName: string; - /** @description LiteLLM provider. */ + /** @description Cloud pricing table vendor slug. */ + vendor: string | null; + /** @description Legacy LiteLLM provider. */ litellmProvider: string | null; /** * Format: date-time @@ -20166,8 +20170,8 @@ export interface operations { manualPrice: { [key: string]: unknown; }; - /** @description LiteLLM price payload. */ - litellmPrice: { + /** @description Cloud price payload. */ + cloudPrice: { [key: string]: unknown; }; }[]; @@ -20583,7 +20587,7 @@ export interface operations { * @description Price source. * @enum {string} */ - source: "litellm" | "manual"; + source: "cloud" | "litellm" | "manual"; /** * Format: date-time * @description Creation time. @@ -20960,7 +20964,7 @@ export interface operations { * @description Price source. * @enum {string} */ - source: "litellm" | "manual"; + source: "cloud" | "litellm" | "manual"; /** * Format: date-time * @description Creation time. diff --git a/src/lib/api/v1/schemas/model-prices.ts b/src/lib/api/v1/schemas/model-prices.ts index 7f474d5d3..96347efa5 100644 --- a/src/lib/api/v1/schemas/model-prices.ts +++ b/src/lib/api/v1/schemas/model-prices.ts @@ -1,7 +1,9 @@ import { z } from "@hono/zod-openapi"; import { IsoDateTimeStringSchema } from "./_common"; -export const ModelPriceSourceSchema = z.enum(["litellm", "manual"]).describe("Model price source."); +export const ModelPriceSourceSchema = z + .enum(["cloud", "litellm", "manual"]) + .describe("Model price source ('litellm' is a legacy value from the old cloud table)."); export const ModelPriceModeSchema = z .enum(["chat", "image_generation", "completion", "responses"]) @@ -20,7 +22,12 @@ export const ModelPriceListQuerySchema = z.object({ pageSize: z.coerce.number().int().min(1).max(100).default(20).describe("Page size."), search: z.string().trim().optional().describe("Optional model search text."), source: ModelPriceSourceSchema.optional().describe("Optional source filter."), - litellmProvider: z.string().trim().optional().describe("Optional LiteLLM provider filter."), + vendor: z.string().trim().optional().describe("Optional cloud vendor filter."), + litellmProvider: z + .string() + .trim() + .optional() + .describe("Legacy LiteLLM provider filter (matches pre-migration rows only)."), }); export const ModelPriceCatalogQuerySchema = z.object({ @@ -50,7 +57,8 @@ export const ModelPriceListResponseSchema = z.object({ export const ModelPriceCatalogItemSchema = z.object({ modelName: z.string().describe("Model name."), - litellmProvider: z.string().nullable().describe("LiteLLM provider."), + vendor: z.string().nullable().describe("Cloud pricing table vendor slug."), + litellmProvider: z.string().nullable().describe("Legacy LiteLLM provider."), updatedAt: IsoDateTimeStringSchema.describe("Last update time."), }); @@ -87,7 +95,7 @@ export const ModelPriceUpdateResultSchema = z.object({ export const ModelPriceSyncConflictSchema = z.object({ modelName: z.string().describe("Conflicting model name."), manualPrice: ModelPriceDataSchema.describe("Manual price payload."), - litellmPrice: ModelPriceDataSchema.describe("LiteLLM price payload."), + cloudPrice: ModelPriceDataSchema.describe("Cloud price payload."), }); export const ModelPriceSyncConflictCheckResponseSchema = z.object({ diff --git a/src/lib/model-vendor-icons.test.ts b/src/lib/model-vendor-icons.test.ts index 0d8ec3d0d..feba1d9c4 100644 --- a/src/lib/model-vendor-icons.test.ts +++ b/src/lib/model-vendor-icons.test.ts @@ -1,171 +1,82 @@ -import { describe, expect, it, vi } from "vitest"; -import { getModelVendor, PRICE_FILTER_VENDORS } from "./model-vendor-icons"; +import { describe, expect, it } from "vitest"; +import { getModelVendor, getVendorEntry, getVendorIconComponent } from "./model-vendor-icons"; describe("getModelVendor", () => { - const cases: Array<{ modelId: string; expectedKey: string | null }> = [ - // Anthropic - { modelId: "claude-sonnet-4-5-20250929", expectedKey: "anthropic" }, - { modelId: "claude-3-opus-20240229", expectedKey: "anthropic" }, - // OpenAI - gpt prefix - { modelId: "gpt-4o-mini", expectedKey: "openai" }, - { modelId: "gpt-5.5", expectedKey: "openai" }, - // OpenAI - chatgpt prefix - { modelId: "chatgpt-4o-latest", expectedKey: "openai" }, - // OpenAI - o1/o3/o4 prefix - { modelId: "o1-preview", expectedKey: "openai" }, - { modelId: "o3-mini", expectedKey: "openai" }, - { modelId: "o4-mini", expectedKey: "openai" }, - // Gemini - { modelId: "gemini-2.5-pro", expectedKey: "vertex" }, - // DeepSeek - { modelId: "deepseek-chat", expectedKey: "deepseek" }, - { modelId: "deepseek-reasoner", expectedKey: "deepseek" }, - // Mistral family - { modelId: "mistral-large-latest", expectedKey: "mistral" }, - { modelId: "mixtral-8x7b-instruct", expectedKey: "mistral" }, - { modelId: "codestral-latest", expectedKey: "mistral" }, - { modelId: "pixtral-large", expectedKey: "mistral" }, - // Meta - { modelId: "llama-3.1-70b", expectedKey: "meta" }, - // Qwen - { modelId: "qwen-turbo-latest", expectedKey: "qwen" }, - // Cohere - { modelId: "command-r-plus", expectedKey: "cohere" }, - // Grok (xAI) - { modelId: "grok-2", expectedKey: "xai" }, - // Perplexity - { modelId: "pplx-70b-online", expectedKey: "perplexity" }, - { modelId: "sonar-pro", expectedKey: "perplexity" }, - // Doubao / Volcengine - { modelId: "doubao-pro-32k", expectedKey: "volcengine" }, - { modelId: "seed-1.6-thinking", expectedKey: "volcengine" }, - // Zhipu - { modelId: "chatglm-4", expectedKey: "zhipuai" }, - { modelId: "glm-4-plus", expectedKey: "zhipuai" }, - // Minimax - { modelId: "minimax-pro", expectedKey: "minimax" }, - { modelId: "abab-6.5", expectedKey: "minimax" }, - // Kimi - { modelId: "kimi-k1.5", expectedKey: "kimi" }, - // Moonshot - { modelId: "moonshot-v1-8k", expectedKey: "moonshot" }, - // Yi - { modelId: "yi-lightning", expectedKey: "yi" }, - // Stepfun - { modelId: "step-2-16k", expectedKey: "stepfun" }, - // Baichuan - { modelId: "baichuan-4", expectedKey: "baichuan" }, - // SenseNova - { modelId: "sensenova-5.5", expectedKey: "sensenova" }, - // Spark - { modelId: "spark-4.0-ultra", expectedKey: "spark" }, - // Hunyuan - { modelId: "hunyuan-pro", expectedKey: "hunyuan" }, - // Wenxin / Ernie - { modelId: "wenxin-4", expectedKey: "wenxin" }, - { modelId: "ernie-4.0-8k", expectedKey: "wenxin" }, - // Gemma - { modelId: "gemma-2-27b", expectedKey: "gemma" }, - // Nvidia - { modelId: "nvidia-nemotron-4-340b", expectedKey: "nvidia" }, - // InternLM - { modelId: "internlm2-20b", expectedKey: "internlm" }, + const cases: Array<{ modelId: string; expectedVendor: string | null }> = [ + { modelId: "claude-sonnet-4-5-20250929", expectedVendor: "anthropic" }, + { modelId: "gpt-4o-mini", expectedVendor: "openai" }, + { modelId: "chatgpt-4o-latest", expectedVendor: "openai" }, + { modelId: "o1-preview", expectedVendor: "openai" }, + { modelId: "gemini-2.5-pro", expectedVendor: "google" }, + { modelId: "deepseek-chat", expectedVendor: "deepseek" }, + { modelId: "mistral-large-latest", expectedVendor: "mistral" }, + { modelId: "mixtral-8x7b-instruct", expectedVendor: "mistral" }, + { modelId: "llama-3.1-70b", expectedVendor: "meta" }, + { modelId: "qwen-turbo-latest", expectedVendor: "alibaba" }, + { modelId: "command-r-plus", expectedVendor: "cohere" }, + { modelId: "grok-2", expectedVendor: "xai" }, + { modelId: "sonar-pro", expectedVendor: "perplexity" }, + { modelId: "doubao-pro-32k", expectedVendor: "bytedance" }, + { modelId: "glm-4-plus", expectedVendor: "zhipuai" }, + { modelId: "kimi-k2", expectedVendor: "moonshotai" }, + { modelId: "yi-lightning", expectedVendor: "01-ai" }, + { modelId: "hunyuan-pro", expectedVendor: "tencent" }, + { modelId: "ernie-4.0-8k", expectedVendor: "baidu" }, + { modelId: "spark-max-32k", expectedVendor: "iflytek" }, + { modelId: "anthropic/claude-sonnet-4-5", expectedVendor: "anthropic" }, + { modelId: "openrouter/deepseek/deepseek-chat", expectedVendor: "deepseek" }, + { modelId: "unknown-model-xyz", expectedVendor: null }, + { modelId: "", expectedVendor: null }, ]; - it.each(cases)("matches '$modelId' -> $expectedKey", ({ modelId, expectedKey }) => { + it.each(cases)("matches '$modelId' -> $expectedVendor", ({ modelId, expectedVendor }) => { const result = getModelVendor(modelId); - if (expectedKey === null) { + if (expectedVendor === null) { expect(result).toBeNull(); } else { expect(result).not.toBeNull(); - expect(result!.i18nKey).toBe(expectedKey); + expect(result?.vendor).toBe(expectedVendor); + expect(result?.displayName.length).toBeGreaterThan(0); } }); - it("is case-insensitive", () => { - expect(getModelVendor("Claude-Sonnet-4-5")?.i18nKey).toBe("anthropic"); - expect(getModelVendor("GPT-4o")?.i18nKey).toBe("openai"); - expect(getModelVendor("DEEPSEEK-CHAT")?.i18nKey).toBe("deepseek"); - }); - - it("returns null for unknown models", () => { - expect(getModelVendor("unknown-model")).toBeNull(); - expect(getModelVendor("custom-model-v2")).toBeNull(); - expect(getModelVendor("some-random-thing")).toBeNull(); - }); - - it("returns null for empty string", () => { - expect(getModelVendor("")).toBeNull(); + it("provides a bundled icon component for major vendors", () => { + for (const modelId of ["claude-3", "gpt-4o", "gemini-2.5-pro", "deepseek-chat"]) { + expect(getModelVendor(modelId)?.icon).toBeTruthy(); + } }); - it("resolves chatglm before glm (longest prefix wins)", () => { - const chatglm = getModelVendor("chatglm-4"); - const glm = getModelVendor("glm-4-plus"); - expect(chatglm?.prefix).toBe("chatglm"); - expect(glm?.prefix).toBe("glm"); - // Both map to zhipuai - expect(chatglm?.i18nKey).toBe("zhipuai"); - expect(glm?.i18nKey).toBe("zhipuai"); + it("provides icon files aligned with the cloud icon map", () => { + expect(getModelVendor("claude-3")?.iconFile?.file).toBe("anthropic.svg"); + expect(getModelVendor("claude-3")?.iconFile?.mono).toBe(true); + expect(getModelVendor("deepseek-chat")?.iconFile?.file).toBe("deepseek-color.svg"); }); +}); - it("resolves grok vs gpt correctly", () => { - expect(getModelVendor("grok-2")?.i18nKey).toBe("xai"); - expect(getModelVendor("gpt-4o")?.i18nKey).toBe("openai"); +describe("getVendorIconComponent", () => { + it("resolves exact vendor slugs", () => { + expect(getVendorIconComponent("anthropic")).toBeTruthy(); + expect(getVendorIconComponent("openai")).toBeTruthy(); + expect(getVendorIconComponent("amazon-bedrock")).toBeTruthy(); }); - it("exact prefix match works", () => { - // Model ID equals exactly the prefix - expect(getModelVendor("gpt")?.i18nKey).toBe("openai"); - expect(getModelVendor("o1")?.i18nKey).toBe("openai"); - expect(getModelVendor("yi")?.i18nKey).toBe("yi"); + it("falls back by longest dash-prefix family", () => { + // alibaba-coding-plan-cn -> alibaba + expect(getVendorIconComponent("alibaba-coding-plan-cn")).toBe( + getVendorIconComponent("alibaba") + ); }); - it("warns in development when a vendor rule has no registered icon", async () => { - const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); - - vi.resetModules(); - vi.doMock("@/lib/model-vendor-rules", () => ({ - getModelVendor: () => ({ - prefix: "missing", - hasColor: false, - i18nKey: "missing-vendor", - }), - })); - vi.stubEnv("NODE_ENV", "development"); - - try { - const { getModelVendor: getMockedModelVendor } = await import("./model-vendor-icons"); - const result = getMockedModelVendor("missing-model"); - - expect(result?.i18nKey).toBe("missing-vendor"); - expect(warnSpy).toHaveBeenCalledWith( - '[model-vendor-icons] No icon registered for i18nKey "missing-vendor"' - ); - } finally { - vi.unstubAllEnvs(); - warnSpy.mockRestore(); - vi.doUnmock("@/lib/model-vendor-rules"); - vi.resetModules(); - } + it("returns null for unknown slugs", () => { + expect(getVendorIconComponent("definitely-unknown-vendor")).toBeNull(); + expect(getVendorIconComponent("")).toBeNull(); }); }); -describe("PRICE_FILTER_VENDORS", () => { - it("has unique litellmProvider values", () => { - const providers = PRICE_FILTER_VENDORS.map((v) => v.litellmProvider); - expect(new Set(providers).size).toBe(providers.length); - }); - - it("has unique i18nKey values", () => { - const keys = PRICE_FILTER_VENDORS.map((v) => v.i18nKey); - expect(new Set(keys).size).toBe(keys.length); - }); - - it("includes core vendors", () => { - const keys = PRICE_FILTER_VENDORS.map((v) => v.i18nKey); - expect(keys).toContain("anthropic"); - expect(keys).toContain("openai"); - expect(keys).toContain("vertex"); - expect(keys).toContain("deepseek"); +describe("getVendorEntry", () => { + it("keeps vendor slug and display name for unregistered vendors", () => { + const entry = getVendorEntry("reka"); + expect(entry.vendor).toBe("reka"); + expect(entry.displayName).toBe("Reka"); }); }); diff --git a/src/lib/model-vendor-icons.tsx b/src/lib/model-vendor-icons.tsx index e2f2dad7c..7c3825a04 100644 --- a/src/lib/model-vendor-icons.tsx +++ b/src/lib/model-vendor-icons.tsx @@ -1,115 +1,229 @@ import { + Ai21, + Ai360, + AlephAlpha, + Alibaba, + AntGroup, + Arcee, + AssemblyAI, + Aws, Azure, + BAAI, Baichuan, + Baidu, Bedrock, - ChatGLM, + Bfl, + ByteDance, Claude, Cohere, + Coqui, + Dbrx, DeepSeek, - Doubao, + ElevenLabs, + EssentialAI, Fireworks, - Gemini, - Gemma, + FishAudio, + Google, Grok, Groq, - Hunyuan, + Haiper, + Hedra, + IBM, + Ideogram, + Inception, + Inflection, InternLM, - Kimi, + Jina, + Kling, + Kwaipilot, + Liquid, + LLaVA, + LongCat, + Luma, Meta, + Microsoft, + Midjourney, Minimax, Mistral, Moonshot, + Morph, + MyShell, + NousResearch, + NovelAI, Nvidia, Ollama, OpenAI, + OpenChat, OpenRouter, Perplexity, + Pika, + PixVerse, Qwen, + Recraft, + Runway, + Rwkv, SenseNova, + Skywork, Spark, + Stability, Stepfun, + Suno, + Tencent, + TII, Together, - Wenxin, + Tripo, + Upstage, + Vidu, + Voyage, + Xuanyuan, + Yandex, Yi, Zhipu, } from "@lobehub/icons"; +import { iconFileForVendor, type VendorIconFileEntry } from "@/lib/model-vendor/vendor-icon-files"; import { - getModelVendor as getModelVendorRule, - type ModelVendorRule, -} from "@/lib/model-vendor-rules"; + inferVendorFromModelName, + UNKNOWN_VENDOR, + vendorDisplayName, +} from "@/lib/model-vendor/vendor-inference"; -export type ModelVendorEntry = ModelVendorRule & { - icon: React.ComponentType<{ className?: string }>; -}; +export type VendorIconComponent = React.ComponentType<{ className?: string }>; -const MODEL_VENDOR_ICON_BY_KEY: Record> = { +/** + * vendor/provider slug -> 本地打包的品牌图标组件(离线可用)。 + * 未覆盖的 slug 由 vendor-icon-files 的云端 SVG(cch-plus.com/model-icons)与 + * monogram 逐级兜底,视觉与云端价格表 providers 字典下发的 icon 一致。 + */ +const VENDOR_ICON_COMPONENTS: Record = { + // 主力厂商(与云端价格表 vendor slug 对齐) anthropic: Claude.Color, - baichuan: Baichuan.Color, - cohere: Cohere.Color, - deepseek: DeepSeek.Color, - gemma: Gemma.Color, - hunyuan: Hunyuan.Color, - internlm: InternLM.Color, - kimi: Kimi.Color, + openai: OpenAI, + google: Google.Color, meta: Meta.Color, - minimax: Minimax.Color, + deepseek: DeepSeek.Color, + alibaba: Alibaba.Color, + qwen: Qwen.Color, mistral: Mistral.Color, - moonshot: Moonshot, - nvidia: Nvidia.Color, - openai: OpenAI, + xai: Grok, + cohere: Cohere.Color, + ai21: Ai21.BrandColor, + moonshotai: Moonshot, + zhipuai: Zhipu.Color, + minimax: Minimax.Color, perplexity: Perplexity.Color, - qwen: Qwen.Color, - sensenova: SenseNova.Color, - spark: Spark.Color, stepfun: Stepfun.Color, - vertex: Gemini.Color, - volcengine: Doubao.Color, - wenxin: Wenxin.Color, - xai: Grok, - yi: Yi.Color, - zhipuai: ChatGLM.Color, + baidu: Baidu.Color, + tencent: Tencent.Color, + bytedance: ByteDance.Color, + "01-ai": Yi.Color, + nvidia: Nvidia.Color, + ibm: IBM, + liquid: Liquid, + amazon: Aws.Color, + inception: Inception, + morph: Morph.Color, + "360": Ai360.Color, + microsoft: Microsoft.Color, + iflytek: Spark.Color, + tii: TII.Color, + jina: Jina, + voyage: Voyage.Color, + baai: BAAI, + bfl: Bfl, + kling: Kling.Color, + recraft: Recraft, + longcat: LongCat.Color, + // LobeHub 品牌兜底集 + alephalpha: AlephAlpha, + antgroup: AntGroup.Color, + arcee: Arcee.Color, + assemblyai: AssemblyAI.Color, + baichuan: Baichuan.Color, + coqui: Coqui.Color, + databricks: Dbrx.Color, + elevenlabs: ElevenLabs, + essentialai: EssentialAI.Color, + fishaudio: FishAudio, + haiper: Haiper, + hedra: Hedra, + ideogram: Ideogram, + inflection: Inflection, + internlm: InternLM.Color, + kwaipilot: Kwaipilot.Color, + llava: LLaVA.Color, + luma: Luma.Color, + midjourney: Midjourney, + myshell: MyShell.Color, + nousresearch: NousResearch, + novelai: NovelAI, + openchat: OpenChat.Color, + pika: Pika, + pixverse: PixVerse.Color, + runway: Runway, + rwkv: Rwkv.Color, + sensenova: SenseNova.Color, + skywork: Skywork.Color, + stability: Stability.Color, + suno: Suno, + tripo: Tripo.Color, + upstage: Upstage.Color, + vidu: Vidu.Color, + xuanyuan: Xuanyuan.Color, + yandex: Yandex, + // 常见 provider 渠道(供应商价格对比等场景) + openrouter: OpenRouter, + groq: Groq, + azure: Azure.Color, + together: Together.Color, + "together-ai": Together.Color, + fireworks: Fireworks.Color, + "fireworks-ai": Fireworks.Color, + ollama: Ollama, + bedrock: Bedrock.Color, + "amazon-bedrock": Bedrock.Color, + "google-vertex": Google.Color, }; -export function getModelVendor(modelId: string): ModelVendorEntry | null { - const rule = getModelVendorRule(modelId); - if (!rule) { - return null; +/** slug 精确命中 -> 最长 dash 前缀家族回退(与云端 icon 解析规则一致) */ +export function getVendorIconComponent(slug: string): VendorIconComponent | null { + const key = slug.trim().toLowerCase(); + if (!key) return null; + if (VENDOR_ICON_COMPONENTS[key]) return VENDOR_ICON_COMPONENTS[key]; + let probe = key; + while (probe.includes("-")) { + probe = probe.slice(0, probe.lastIndexOf("-")); + if (VENDOR_ICON_COMPONENTS[probe]) return VENDOR_ICON_COMPONENTS[probe]; } + return null; +} - const icon = MODEL_VENDOR_ICON_BY_KEY[rule.i18nKey]; - if (!icon && process.env.NODE_ENV !== "production") { - console.warn(`[model-vendor-icons] No icon registered for i18nKey "${rule.i18nKey}"`); - } +export interface ModelVendorEntry { + /** 云端价格表口径的 vendor slug */ + vendor: string; + displayName: string; + /** 本地打包的图标组件(可能为空,走远程 SVG/monogram 兜底) */ + icon: VendorIconComponent | null; + /** 云端 SVG 图标文件(cch-plus.com/model-icons/) */ + iconFile: VendorIconFileEntry | null; +} +/** + * 按模型调用名推断厂商(正则规则与云端价格表生成侧一致)。 + * 未识别(vendor=other)返回 null。 + */ +export function getModelVendor(modelId: string): ModelVendorEntry | null { + if (!modelId) return null; + const vendor = inferVendorFromModelName(modelId); + if (vendor === UNKNOWN_VENDOR) return null; + return getVendorEntry(vendor); +} + +/** 按 vendor slug 组装图标条目 */ +export function getVendorEntry(vendor: string): ModelVendorEntry { return { - ...rule, - icon: icon ?? OpenAI, + vendor, + displayName: vendorDisplayName(vendor), + icon: getVendorIconComponent(vendor), + iconFile: iconFileForVendor(vendor), }; } - -export const PRICE_FILTER_VENDORS: Array<{ - i18nKey: string; - litellmProvider: string; - icon: React.ComponentType<{ className?: string }>; -}> = [ - { i18nKey: "anthropic", litellmProvider: "anthropic", icon: Claude.Color }, - { i18nKey: "openai", litellmProvider: "openai", icon: OpenAI }, - { i18nKey: "vertex", litellmProvider: "vertex_ai-language-models", icon: Gemini.Color }, - { i18nKey: "deepseek", litellmProvider: "deepseek", icon: DeepSeek.Color }, - { i18nKey: "mistral", litellmProvider: "mistral", icon: Mistral.Color }, - { i18nKey: "meta", litellmProvider: "meta", icon: Meta.Color }, - { i18nKey: "cohere", litellmProvider: "cohere_chat", icon: Cohere.Color }, - { i18nKey: "xai", litellmProvider: "xai", icon: Grok }, - { i18nKey: "groq", litellmProvider: "groq", icon: Groq }, - { i18nKey: "bedrock", litellmProvider: "bedrock", icon: Bedrock.Color }, - { i18nKey: "azure", litellmProvider: "azure", icon: Azure.Color }, - { i18nKey: "together", litellmProvider: "together_ai", icon: Together.Color }, - { i18nKey: "nvidia", litellmProvider: "nvidia_nim", icon: Nvidia.Color }, - { i18nKey: "zhipuai", litellmProvider: "zhipuai", icon: Zhipu.Color }, - { i18nKey: "volcengine", litellmProvider: "volcengine", icon: Doubao.Color }, - { i18nKey: "minimax", litellmProvider: "minimax", icon: Minimax.Color }, - { i18nKey: "qwen", litellmProvider: "qwen", icon: Qwen.Color }, - { i18nKey: "fireworks", litellmProvider: "fireworks_ai", icon: Fireworks.Color }, - { i18nKey: "ollama", litellmProvider: "ollama", icon: Ollama }, - { i18nKey: "openrouter", litellmProvider: "openrouter", icon: OpenRouter }, -]; diff --git a/src/lib/model-vendor-rules.ts b/src/lib/model-vendor-rules.ts deleted file mode 100644 index 2ad4ff0b4..000000000 --- a/src/lib/model-vendor-rules.ts +++ /dev/null @@ -1,119 +0,0 @@ -// Strictly sorted by prefix length descending to ensure longest-match-first. -// Within same length, sorted alphabetically. -export const MODEL_VENDOR_RULES = [ - { - prefix: "codestral", - hasColor: true, - i18nKey: "mistral", - litellmProvider: "mistral", - }, - { prefix: "sensenova", hasColor: true, i18nKey: "sensenova" }, - { prefix: "baichuan", hasColor: true, i18nKey: "baichuan" }, - { - prefix: "deepseek", - hasColor: true, - i18nKey: "deepseek", - litellmProvider: "deepseek", - }, - { prefix: "internlm", hasColor: true, i18nKey: "internlm" }, - { prefix: "moonshot", hasColor: false, i18nKey: "moonshot" }, - { - prefix: "chatglm", - hasColor: true, - i18nKey: "zhipuai", - litellmProvider: "zhipuai", - }, - { - prefix: "chatgpt", - hasColor: false, - i18nKey: "openai", - litellmProvider: "openai", - }, - { - prefix: "command", - hasColor: true, - i18nKey: "cohere", - litellmProvider: "cohere_chat", - }, - { prefix: "hunyuan", hasColor: true, i18nKey: "hunyuan" }, - { prefix: "minimax", hasColor: true, i18nKey: "minimax" }, - { - prefix: "mistral", - hasColor: true, - i18nKey: "mistral", - litellmProvider: "mistral", - }, - { - prefix: "mixtral", - hasColor: true, - i18nKey: "mistral", - litellmProvider: "mistral", - }, - { - prefix: "pixtral", - hasColor: true, - i18nKey: "mistral", - litellmProvider: "mistral", - }, - { - prefix: "claude", - hasColor: true, - i18nKey: "anthropic", - litellmProvider: "anthropic", - }, - { - prefix: "doubao", - hasColor: true, - i18nKey: "volcengine", - litellmProvider: "volcengine", - }, - { - prefix: "gemini", - hasColor: true, - i18nKey: "vertex", - litellmProvider: "vertex_ai-language-models", - }, - { prefix: "nvidia", hasColor: true, i18nKey: "nvidia" }, - { prefix: "wenxin", hasColor: true, i18nKey: "wenxin" }, - { prefix: "ernie", hasColor: true, i18nKey: "wenxin" }, - { prefix: "gemma", hasColor: true, i18nKey: "gemma" }, - { prefix: "llama", hasColor: true, i18nKey: "meta" }, - { prefix: "sonar", hasColor: true, i18nKey: "perplexity" }, - { prefix: "spark", hasColor: true, i18nKey: "spark" }, - { prefix: "abab", hasColor: true, i18nKey: "minimax" }, - { prefix: "grok", hasColor: false, i18nKey: "xai", litellmProvider: "xai" }, - { prefix: "kimi", hasColor: true, i18nKey: "kimi" }, - { prefix: "pplx", hasColor: true, i18nKey: "perplexity" }, - { prefix: "qwen", hasColor: true, i18nKey: "qwen" }, - { - prefix: "seed", - hasColor: true, - i18nKey: "volcengine", - litellmProvider: "volcengine", - }, - { prefix: "step", hasColor: true, i18nKey: "stepfun" }, - { - prefix: "glm", - hasColor: true, - i18nKey: "zhipuai", - litellmProvider: "zhipuai", - }, - { prefix: "gpt", hasColor: false, i18nKey: "openai", litellmProvider: "openai" }, - { prefix: "o1", hasColor: false, i18nKey: "openai", litellmProvider: "openai" }, - { prefix: "o3", hasColor: false, i18nKey: "openai", litellmProvider: "openai" }, - { prefix: "o4", hasColor: false, i18nKey: "openai", litellmProvider: "openai" }, - { prefix: "yi", hasColor: true, i18nKey: "yi" }, -] as const; - -export type ModelVendorRule = (typeof MODEL_VENDOR_RULES)[number]; - -export function getModelVendor(modelId: string): ModelVendorRule | null { - if (!modelId) return null; - const lower = modelId.toLowerCase(); - for (const rule of MODEL_VENDOR_RULES) { - if (lower.startsWith(rule.prefix)) { - return rule; - } - } - return null; -} diff --git a/src/lib/model-vendor/vendor-icon-files.ts b/src/lib/model-vendor/vendor-icon-files.ts new file mode 100644 index 000000000..05233bc8e --- /dev/null +++ b/src/lib/model-vendor/vendor-icon-files.ts @@ -0,0 +1,42 @@ +// Vendor slug -> LobeHub static SVG icon file resolution. +// vendor-icon-map.json is a verbatim copy of the cch-plus.com official website +// icon map, so icons resolved here match the `icon` fields published in the +// cloud pricing table (served at https://cch-plus.com/model-icons/). +import iconMap from "./vendor-icon-map.json"; + +export interface VendorIconFileEntry { + file: string; + mono?: boolean; +} + +const ICONS = iconMap as Record; + +export const CLOUD_MODEL_ICON_BASE_URL = "https://cch-plus.com/model-icons/"; + +/** 拼出云端 SVG 图标的完整地址;file 为价格表下发/映射表内的基名 */ +export function cloudModelIconUrl(file: string): string { + return `${CLOUD_MODEL_ICON_BASE_URL}${file}`; +} + +/** 精确命中 -> 最长前缀家族(alibaba-coding-plan-cn -> alibaba)回退;都没有返回 null */ +export function iconFileForVendor(slug: string): VendorIconFileEntry | null { + const key = slug.trim().toLowerCase(); + if (!key) return null; + if (ICONS[key]) return ICONS[key]; + let probe = key; + while (probe.includes("-")) { + probe = probe.slice(0, probe.lastIndexOf("-")); + if (ICONS[probe]) return ICONS[probe]; + } + return null; +} + +/** 任意字符串 -> 确定性强调色(固定明度/彩度,色相走 hash),用于 monogram 兜底 */ +export function accentColorOf(seed: string): string { + let h = 0; + for (const ch of seed.toLowerCase()) { + h = (h * 31 + (ch.codePointAt(0) ?? 0)) | 0; + } + const hue = ((h % 360) + 360) % 360; + return `oklch(0.62 0.13 ${hue})`; +} diff --git a/src/lib/model-vendor/vendor-icon-map.json b/src/lib/model-vendor/vendor-icon-map.json new file mode 100644 index 000000000..9f628470c --- /dev/null +++ b/src/lib/model-vendor/vendor-icon-map.json @@ -0,0 +1,153 @@ +{ + "01-ai": { "file": "zeroone-color.svg", "mono": false }, + "302ai": { "file": "ai302-color.svg", "mono": false }, + "360": { "file": "ai360-color.svg", "mono": false }, + "ai21": { "file": "ai21.svg", "mono": true }, + "ai360": { "file": "ai360-color.svg", "mono": false }, + "aihubmix": { "file": "aihubmix-color.svg", "mono": false }, + "alibaba": { "file": "alibaba-color.svg", "mono": false }, + "alibaba-cn": { "file": "alibaba-color.svg", "mono": false }, + "alibaba-coding-plan": { "file": "alibaba-color.svg", "mono": false }, + "alibaba-coding-plan-cn": { "file": "alibaba-color.svg", "mono": false }, + "alibaba-token-plan": { "file": "alibaba-color.svg", "mono": false }, + "alibaba-token-plan-cn": { "file": "alibaba-color.svg", "mono": false }, + "amazon": { "file": "aws-color.svg", "mono": false }, + "amazon-bedrock": { "file": "bedrock-color.svg", "mono": false }, + "antgroup": { "file": "antgroup-color.svg", "mono": false }, + "anthropic": { "file": "anthropic.svg", "mono": true }, + "arcee": { "file": "arcee-color.svg", "mono": false }, + "aws-bedrock-mantle": { "file": "bedrock-color.svg", "mono": false }, + "azure": { "file": "azure-color.svg", "mono": false }, + "azure-ai-foundry": { "file": "azure-color.svg", "mono": false }, + "azure-cognitive-services": { "file": "azure-color.svg", "mono": false }, + "azureai": { "file": "azureai-color.svg", "mono": false }, + "baai": { "file": "baai.svg", "mono": true }, + "baichuan": { "file": "baichuan-color.svg", "mono": false }, + "baidu": { "file": "baidu-color.svg", "mono": false }, + "baseten": { "file": "baseten.svg", "mono": true }, + "bedrock": { "file": "bedrock-color.svg", "mono": false }, + "bfl": { "file": "bfl.svg", "mono": true }, + "bytedance": { "file": "bytedance-color.svg", "mono": false }, + "cerebras": { "file": "cerebras-color.svg", "mono": false }, + "cloudflare-ai-gateway": { "file": "cloudflare-color.svg", "mono": false }, + "cloudflare-workers-ai": { "file": "workersai-color.svg", "mono": false }, + "cohere": { "file": "cohere-color.svg", "mono": false }, + "databricks": { "file": "dbrx-color.svg", "mono": false }, + "deepinfra": { "file": "deepinfra-color.svg", "mono": false }, + "deepseek": { "file": "deepseek-color.svg", "mono": false }, + "elevenlabs": { "file": "elevenlabs.svg", "mono": true }, + "essentialai": { "file": "essentialai-color.svg", "mono": false }, + "fal": { "file": "fal-color.svg", "mono": false }, + "fireworks-ai": { "file": "fireworks-color.svg", "mono": false }, + "fireworksai": { "file": "fireworks-color.svg", "mono": false }, + "friendli": { "file": "friendli.svg", "mono": true }, + "github": { "file": "github.svg", "mono": true }, + "github-copilot": { "file": "githubcopilot.svg", "mono": true }, + "github-models": { "file": "github.svg", "mono": true }, + "google": { "file": "google-color.svg", "mono": false }, + "google-vertex": { "file": "vertexai-color.svg", "mono": false }, + "google-vertex-anthropic": { "file": "vertexai-color.svg", "mono": false }, + "groq": { "file": "groq.svg", "mono": true }, + "higress": { "file": "higress-color.svg", "mono": false }, + "huggingface": { "file": "huggingface-color.svg", "mono": false }, + "hunyuan": { "file": "hunyuan-color.svg", "mono": false }, + "hyperbolic": { "file": "hyperbolic-color.svg", "mono": false }, + "ibm": { "file": "ibm.svg", "mono": true }, + "ibm-watsonx": { "file": "ibm.svg", "mono": true }, + "ideogram": { "file": "ideogram.svg", "mono": true }, + "inception": { "file": "inception.svg", "mono": true }, + "inference": { "file": "inference.svg", "mono": true }, + "infiniai": { "file": "infinigence-color.svg", "mono": false }, + "inflection": { "file": "inflection.svg", "mono": true }, + "internlm": { "file": "internlm-color.svg", "mono": false }, + "jina": { "file": "jina.svg", "mono": true }, + "kilo": { "file": "kilocode.svg", "mono": true }, + "kimi-for-coding": { "file": "kimi-color.svg", "mono": false }, + "kling": { "file": "kling-color.svg", "mono": false }, + "kwaipilot": { "file": "kwaipilot-color.svg", "mono": false }, + "lambda": { "file": "lambda.svg", "mono": true }, + "liquid": { "file": "liquid.svg", "mono": true }, + "llama": { "file": "meta-color.svg", "mono": false }, + "llava": { "file": "llava-color.svg", "mono": false }, + "lmstudio": { "file": "lmstudio.svg", "mono": true }, + "longcat": { "file": "longcat-color.svg", "mono": false }, + "luma": { "file": "luma-color.svg", "mono": false }, + "meta": { "file": "meta-color.svg", "mono": false }, + "microsoft": { "file": "microsoft-color.svg", "mono": false }, + "minimax": { "file": "minimax-color.svg", "mono": false }, + "minimax-cn": { "file": "minimax-color.svg", "mono": false }, + "minimax-cn-coding-plan": { "file": "minimax-color.svg", "mono": false }, + "minimax-coding-plan": { "file": "minimax-color.svg", "mono": false }, + "mistral": { "file": "mistral-color.svg", "mono": false }, + "modelscope": { "file": "modelscope-color.svg", "mono": false }, + "moonshotai": { "file": "moonshot.svg", "mono": true }, + "moonshotai-cn": { "file": "moonshot.svg", "mono": true }, + "morph": { "file": "morph-color.svg", "mono": false }, + "myshell": { "file": "myshell-color.svg", "mono": false }, + "nebius": { "file": "nebius.svg", "mono": true }, + "nousresearch": { "file": "nousresearch.svg", "mono": true }, + "nova": { "file": "nova-color.svg", "mono": false }, + "novita": { "file": "novita-color.svg", "mono": false }, + "novita-ai": { "file": "novita-color.svg", "mono": false }, + "nvidia": { "file": "nvidia-color.svg", "mono": false }, + "ollama-cloud": { "file": "ollama.svg", "mono": true }, + "openai": { "file": "openai.svg", "mono": true }, + "openchat": { "file": "openchat-color.svg", "mono": false }, + "opencode": { "file": "opencode.svg", "mono": true }, + "opencode-go": { "file": "opencode.svg", "mono": true }, + "opencode-zen": { "file": "opencode.svg", "mono": true }, + "opencodeCodingPlan": { "file": "opencode.svg", "mono": true }, + "opencodeZen": { "file": "opencode.svg", "mono": true }, + "openrouter": { "file": "openrouter.svg", "mono": true }, + "perplexity": { "file": "perplexity-color.svg", "mono": false }, + "perplexity-agent": { "file": "perplexity-color.svg", "mono": false }, + "pixverse": { "file": "pixverse-color.svg", "mono": false }, + "poe": { "file": "poe-color.svg", "mono": false }, + "ppio": { "file": "ppio-color.svg", "mono": false }, + "qiniu": { "file": "qiniu-color.svg", "mono": false }, + "qiniu-ai": { "file": "qiniu-color.svg", "mono": false }, + "recraft": { "file": "recraft.svg", "mono": true }, + "replicate": { "file": "replicate.svg", "mono": true }, + "runway": { "file": "runway.svg", "mono": true }, + "sambanova": { "file": "sambanova-color.svg", "mono": false }, + "sensenova": { "file": "sensenova-color.svg", "mono": false }, + "siliconcloud": { "file": "siliconcloud-color.svg", "mono": false }, + "siliconflow": { "file": "siliconcloud-color.svg", "mono": false }, + "siliconflow-cn": { "file": "siliconcloud-color.svg", "mono": false }, + "skywork": { "file": "skywork-color.svg", "mono": false }, + "snowflake": { "file": "snowflake-color.svg", "mono": false }, + "snowflake-cortex": { "file": "snowflake-color.svg", "mono": false }, + "stability": { "file": "stability-color.svg", "mono": false }, + "stepfun": { "file": "stepfun-color.svg", "mono": false }, + "stepfun-ai": { "file": "stepfun-color.svg", "mono": false }, + "streamlake": { "file": "streamlake-color.svg", "mono": false }, + "submodel": { "file": "submodel-color.svg", "mono": false }, + "tencent": { "file": "tencent-color.svg", "mono": false }, + "tencent-coding-plan": { "file": "tencent-color.svg", "mono": false }, + "tencent-token-plan": { "file": "tencent-color.svg", "mono": false }, + "tencent-tokenhub": { "file": "tencent-color.svg", "mono": false }, + "tencentcloud": { "file": "tencentcloud-color.svg", "mono": false }, + "togetherai": { "file": "together-color.svg", "mono": false }, + "upstage": { "file": "upstage-color.svg", "mono": false }, + "v0": { "file": "v0.svg", "mono": true }, + "venice": { "file": "venice-color.svg", "mono": false }, + "vercel": { "file": "vercel.svg", "mono": true }, + "vercelaigateway": { "file": "vercel.svg", "mono": true }, + "vertexai": { "file": "vertexai-color.svg", "mono": false }, + "vidu": { "file": "vidu-color.svg", "mono": false }, + "volcengine": { "file": "volcengine-color.svg", "mono": false }, + "voyage": { "file": "voyage-color.svg", "mono": false }, + "wenxin": { "file": "wenxin-color.svg", "mono": false }, + "xai": { "file": "xai.svg", "mono": true }, + "xiaomi": { "file": "xiaomimimo.svg", "mono": true }, + "xiaomi-token-plan-ams": { "file": "xiaomimimo.svg", "mono": true }, + "xiaomi-token-plan-cn": { "file": "xiaomimimo.svg", "mono": true }, + "xiaomi-token-plan-sgp": { "file": "xiaomimimo.svg", "mono": true }, + "xiaomimimo": { "file": "xiaomimimo.svg", "mono": true }, + "zai": { "file": "zai.svg", "mono": true }, + "zai-coding-plan": { "file": "zai.svg", "mono": true }, + "zenmux": { "file": "zenmux.svg", "mono": true }, + "zeroone": { "file": "zeroone-color.svg", "mono": false }, + "zhipuai": { "file": "zhipu-color.svg", "mono": false }, + "zhipuai-coding-plan": { "file": "zhipu-color.svg", "mono": false } +} diff --git a/src/lib/model-vendor/vendor-inference.test.ts b/src/lib/model-vendor/vendor-inference.test.ts new file mode 100644 index 000000000..73bb7abb5 --- /dev/null +++ b/src/lib/model-vendor/vendor-inference.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it } from "vitest"; +import { + inferVendorFromModelName, + isHostPrefix, + keywordScan, + stripRegionPrefix, + UNKNOWN_VENDOR, + vendorDisplayName, + vendorOfPrefix, +} from "./vendor-inference"; + +describe("inferVendorFromModelName", () => { + const cases: Array<{ modelId: string; expected: string }> = [ + // 主力厂商裸名 + { modelId: "claude-sonnet-4-5-20250929", expected: "anthropic" }, + { modelId: "claude-3-opus-20240229", expected: "anthropic" }, + { modelId: "gpt-4o-mini", expected: "openai" }, + { modelId: "gpt-5.5", expected: "openai" }, + { modelId: "chatgpt-4o-latest", expected: "openai" }, + { modelId: "o1-preview", expected: "openai" }, + { modelId: "o3-mini", expected: "openai" }, + { modelId: "dall-e-3", expected: "openai" }, + { modelId: "gemini-2.5-pro", expected: "google" }, + { modelId: "gemma-2-27b", expected: "google" }, + { modelId: "imagen-3.0-generate-002", expected: "google" }, + { modelId: "deepseek-chat", expected: "deepseek" }, + { modelId: "deepseek-reasoner", expected: "deepseek" }, + { modelId: "mistral-large-latest", expected: "mistral" }, + { modelId: "mixtral-8x7b-instruct", expected: "mistral" }, + { modelId: "codestral-latest", expected: "mistral" }, + { modelId: "pixtral-large", expected: "mistral" }, + { modelId: "ministral-8b", expected: "mistral" }, + { modelId: "llama-3.1-70b", expected: "meta" }, + { modelId: "codellama-34b", expected: "meta" }, + { modelId: "qwen-turbo-latest", expected: "alibaba" }, + { modelId: "qwq-32b", expected: "alibaba" }, + { modelId: "command-r-plus", expected: "cohere" }, + { modelId: "grok-2", expected: "xai" }, + { modelId: "pplx-70b-online", expected: "perplexity" }, + { modelId: "sonar-pro", expected: "perplexity" }, + { modelId: "doubao-pro-32k", expected: "bytedance" }, + { modelId: "seed-1.6-thinking", expected: "bytedance" }, + { modelId: "seedance-1-0-pro", expected: "bytedance" }, + { modelId: "chatglm-4", expected: "zhipuai" }, + { modelId: "glm-4-plus", expected: "zhipuai" }, + { modelId: "minimax-pro", expected: "minimax" }, + { modelId: "abab-6.5", expected: "minimax" }, + { modelId: "kimi-k2", expected: "moonshotai" }, + { modelId: "moonshot-v1-8k", expected: "moonshotai" }, + { modelId: "yi-lightning", expected: "01-ai" }, + { modelId: "step-2-16k", expected: "stepfun" }, + { modelId: "baichuan-4", expected: "baichuan" }, + { modelId: "sensenova-5.5", expected: "sensenova" }, + { modelId: "spark-max-32k", expected: "iflytek" }, + { modelId: "hunyuan-pro", expected: "tencent" }, + { modelId: "wenxin-4", expected: "baidu" }, + { modelId: "ernie-4.0-8k", expected: "baidu" }, + { modelId: "nvidia-nemotron-4-340b", expected: "nvidia" }, + { modelId: "internlm2-20b", expected: "internlm" }, + { modelId: "granite-3.1-8b", expected: "ibm" }, + { modelId: "jamba-1.5-large", expected: "ai21" }, + { modelId: "phi-4", expected: "microsoft" }, + { modelId: "falcon-180b", expected: "tii" }, + { modelId: "flux-pro-1.1", expected: "bfl" }, + { modelId: "360gpt2-pro", expected: "360" }, + // 蒸馏/衍生名归发布方 + { modelId: "deepseek-r1-distill-qwen-32b", expected: "deepseek" }, + { modelId: "llama-3.1-nemotron-70b", expected: "nvidia" }, + // 带 vendor 前缀的斜杠调用名 + { modelId: "anthropic/claude-sonnet-4-5", expected: "anthropic" }, + { modelId: "openai/gpt-5.5", expected: "openai" }, + { modelId: "deepseek-ai/DeepSeek-V3.2", expected: "deepseek" }, + { modelId: "meta-llama/llama-3.3-70b-instruct", expected: "meta" }, + { modelId: "x-ai/grok-4", expected: "xai" }, + { modelId: "z-ai/glm-4.7", expected: "zhipuai" }, + // 托管商前缀跳过 org、按模型段识别 + { modelId: "openrouter/deepseek/deepseek-chat", expected: "deepseek" }, + { modelId: "together/meta-llama/Llama-3-70b", expected: "meta" }, + { modelId: "hf/deepseek-ai/DeepSeek-V3.2", expected: "deepseek" }, + { modelId: "novita/qwen/qwen3-32b", expected: "alibaba" }, + // Cloudflare Workers AI + { modelId: "@cf/meta/llama-3-8b-instruct", expected: "meta" }, + { modelId: "@cf/facebook/bart-large-cnn", expected: "meta" }, + // bedrock 区域/点前缀 + { modelId: "us.anthropic.claude-sonnet-4-5-20250929-v1:0", expected: "anthropic" }, + { modelId: "eu.amazon.nova-pro-v1:0", expected: "amazon" }, + // 托管商前缀污染的扁平名 + { modelId: "coding-glm-4.7", expected: "zhipuai" }, + // LobeHub 品牌兜底 + { modelId: "hailuo-video-01", expected: "minimax" }, + { modelId: "stability-sd3.5-large", expected: "stability" }, + { modelId: "suno-v4", expected: "suno" }, + ]; + + it.each(cases)("infers '$modelId' -> $expected", ({ modelId, expected }) => { + expect(inferVendorFromModelName(modelId)).toBe(expected); + }); + + it("is case-insensitive", () => { + expect(inferVendorFromModelName("Claude-Sonnet-4-5")).toBe("anthropic"); + expect(inferVendorFromModelName("GPT-4o")).toBe("openai"); + expect(inferVendorFromModelName("DEEPSEEK-CHAT")).toBe("deepseek"); + }); + + it("returns 'other' for unknown models", () => { + expect(inferVendorFromModelName("custom-model-v2")).toBe(UNKNOWN_VENDOR); + expect(inferVendorFromModelName("some-random-thing")).toBe(UNKNOWN_VENDOR); + expect(inferVendorFromModelName("")).toBe(UNKNOWN_VENDOR); + }); + + it("prefers publisher keyword over host org", () => { + // fireworks 是托管商,应识别到 qwen -> alibaba + expect(inferVendorFromModelName("fireworks/models/qwen2p5-14b-instruct")).toBe("alibaba"); + }); +}); + +describe("stripRegionPrefix", () => { + it("strips bedrock region prefixes iteratively", () => { + expect(stripRegionPrefix("us.anthropic.claude-3")).toBe("anthropic.claude-3"); + expect(stripRegionPrefix("us-gov.anthropic.claude-3")).toBe("anthropic.claude-3"); + expect(stripRegionPrefix("claude-3")).toBe("claude-3"); + }); +}); + +describe("vendorOfPrefix", () => { + it("normalizes known prefixes", () => { + expect(vendorOfPrefix("qwen")).toBe("alibaba"); + expect(vendorOfPrefix("volcengine")).toBe("bytedance"); + expect(vendorOfPrefix("moonshot")).toBe("moonshotai"); + expect(vendorOfPrefix("unknown-org")).toBeUndefined(); + expect(vendorOfPrefix(undefined)).toBeUndefined(); + }); +}); + +describe("keywordScan", () => { + it("applies first-match ordering (deepseek before qwen)", () => { + expect(keywordScan("deepseek-r1-distill-qwen-32b")).toBe("deepseek"); + }); +}); + +describe("isHostPrefix", () => { + it("recognizes hosting orgs", () => { + expect(isHostPrefix("openrouter")).toBe(true); + expect(isHostPrefix("HF")).toBe(true); + expect(isHostPrefix("anthropic")).toBe(false); + }); +}); + +describe("vendorDisplayName", () => { + it("resolves known display names and falls back to slug", () => { + expect(vendorDisplayName("bfl")).toBe("Black Forest Labs"); + expect(vendorDisplayName("anthropic")).toBe("Anthropic"); + expect(vendorDisplayName("unregistered-vendor")).toBe("unregistered-vendor"); + }); +}); diff --git a/src/lib/model-vendor/vendor-inference.ts b/src/lib/model-vendor/vendor-inference.ts new file mode 100644 index 000000000..30083cd66 --- /dev/null +++ b/src/lib/model-vendor/vendor-inference.ts @@ -0,0 +1,392 @@ +// Vendor inference for model call names. +// Ported from the cch-plus.com official website pricing pipeline (registry.ts) +// so that this project shares the exact same regex-based matching rules used to +// generate the cloud pricing table. Keyword rules are substring regex scans +// (first-match by array order); host prefixes are skipped when scanning +// "org/model" style names. + +/** Bedrock-style region prefixes (us.anthropic.… / us-gov.…). */ +const REGION_PREFIX = /^(us|eu|jp|au|apac|global|us-gov|ca|sa)\./; + +export function stripRegionPrefix(value: string): string { + let out = value; + while (REGION_PREFIX.test(out)) out = out.replace(REGION_PREFIX, ""); + return out; +} + +/** + * Call-name prefix -> vendor slug ("openai/gpt-5.5" 的 "openai"、HF 风格 "deepseek-ai" 等)。 + */ +export const PREFIX_VENDOR_ALIAS: Record = { + anthropic: "anthropic", + openai: "openai", + google: "google", + "meta-llama": "meta", + meta: "meta", + llama: "meta", + deepseek: "deepseek", + "deepseek-ai": "deepseek", + qwen: "alibaba", + alibaba: "alibaba", + mistral: "mistral", + mistralai: "mistral", + xai: "xai", + "x-ai": "xai", + cohere: "cohere", + ai21: "ai21", + moonshotai: "moonshotai", + moonshot: "moonshotai", + zhipuai: "zhipuai", + "z-ai": "zhipuai", + thudm: "zhipuai", + minimax: "minimax", + minimaxai: "minimax", + perplexity: "perplexity", + pplx: "perplexity", + stepfun: "stepfun", + "stepfun-ai": "stepfun", + baidu: "baidu", + tencent: "tencent", + bytedance: "bytedance", + "bytedance-seed": "bytedance", + volcengine: "bytedance", + doubao: "bytedance", + seed: "bytedance", + seedance: "bytedance", + xiaomi: "xiaomi", + xiaomimimo: "xiaomi", + mimo: "xiaomi", + "01-ai": "01-ai", + reka: "reka", + nvidia: "nvidia", + ibm: "ibm", + "ibm-granite": "ibm", + liquid: "liquid", + amazon: "amazon", + inception: "inception", + morph: "morph", +}; + +/** 调用名的 vendor 前缀归一;无前缀或未知前缀返回 undefined */ +export function vendorOfPrefix(prefix: string | undefined): string | undefined { + if (!prefix) return undefined; + return PREFIX_VENDOR_ALIAS[prefix.toLowerCase()]; +} + +/** 托管/网关 org 前缀:出现在 "org/model" 斜杠前时应跳过 org、改扫 model 段 */ +const HOST_PREFIXES = new Set([ + "ppio", + "sophnet", + "together", + "togetherai", + "together-ai", + "fireworks", + "fireworks-ai", + "openrouter", + "deepinfra", + "novita", + "novita-ai", + "siliconflow", + "siliconflow-cn", + "302ai", + "aihubmix", + "unsloth", + "bartowski", + "thebloke", + "huggingface", + "hf", + "modelscope", + "replicate", + "nebius", + "hyperbolic", + "featherless", + "parasail", + "gmicloud", + "kluster", + "lambda", + "cloudflare", + "vercel", + "portkey", + "requesty", + "nscale", + "inference-net", + "venice", + "kenari", + "qiniu-ai", + "opencode-go", + "coding", +]); + +export function isHostPrefix(prefix: string): boolean { + return HOST_PREFIXES.has(prefix.toLowerCase()); +} + +/** + * 模型名关键词 -> vendor(全串子串匹配,按数组序 first-match;更"具体/发布方"的规则在前)。 + * 蒸馏/衍生名(deepseek-r1-distill-qwen、nemotron-llama)归发布方,故 deepseek/nvidia 早于 qwen/llama。 + */ +export const KEYWORD_VENDOR_RULES: ReadonlyArray = [ + [/xiaomimimo|xiaomi|\bmimo\b/, "xiaomi"], + [/deepseek/, "deepseek"], + [/doubao|seedance|seedream|seed-oss|\bseed-\d|ui-tars|bytedance|volcengine/, "bytedance"], + [/nemotron|\bnvidia\b/, "nvidia"], + [/\bphi-?\d|wizardlm|\borca-2|\bmai-(ds|voice|\d)/, "microsoft"], + [/\byi-\d|\byi-(lightning|vision|large|medium|coder|spark)|\b01-?ai|yi1\.5/, "01-ai"], + [/sparkdesk|iflytek|spark-(max|lite|pro|ultra|x1)|\bspark4/, "iflytek"], + [/360gpt|360zhinao/, "360"], + [/kimi|moonshot/, "moonshotai"], + [/ernie|wenxin|qianfan/, "baidu"], + [ + /hunyuan|\bhy-?\d|\bhy-(mt|image|video|3d|t1|turbo|large|standard|lite|vision|role|a13b|dense|moe|code)/, + "tencent", + ], + [/grok/, "xai"], + [/\bcommand-?(r|a|light|nightly)|\bcommand\b/, "cohere"], + [/pixtral|codestral|ministral|magistral|devstral|mixtral|mistral/, "mistral"], + [/granite/, "ibm"], + [/\blfm-?\d|\blfm\b/, "liquid"], + [/jamba/, "ai21"], + [/stepfun|\bstep-\d|step-r1|step-audio/, "stepfun"], + [/minimax|\babab/, "minimax"], + [/\breka-|\breka\b/, "reka"], + [/sonar|perplexity/, "perplexity"], + [/falcon/, "tii"], + [/deepgram/, "deepgram"], + [/\bjina/, "jina"], + [/voyage/, "voyage"], + [/\bbge-|\bbge\b|baai/, "baai"], + [/black-forest|\bflux-?\d|\bflux-(pro|dev|schnell|kontext|krea|1)|\bflux\b/, "bfl"], + [/\bkling/, "kling"], + [/recraft/, "recraft"], + [/longcat/, "longcat"], + [ + /\bling-(lite|plus|flash|mini|coder|omni|1t|\d)|\bling\b|bailing|inclusionai|\bring-(lite|flash|mini|1t)/, + "antgroup", + ], + [/chatglm|autoglm|charglm|codegeex|cogview|cogvideo|\bglm-?\d|\bglm-|\bglm\b|zhipu/, "zhipuai"], + [/qwen|qwq|qvq|tongyi|wanx|marco-o1/, "alibaba"], + [/gemini|gemma|\bpalm-2|imagen|nano-banana|\bbison\b|\bgecko\b/, "google"], + [/codellama|llama/, "meta"], + [ + /\bgpt-|\bo1-|\bo3-|\bo4-|davinci|\bwhisper\b|dall-e|\bchatgpt|text-embedding-(ada|3)/, + "openai", + ], + [/claude/, "anthropic"], + [/facebook|\bbart-|\bopt-\d|blenderbot/, "meta"], +]; + +/** 全串关键词扫描:命中第一条规则即返回其 vendor,否则 undefined */ +export function keywordScan(value: string): string | undefined { + for (const [re, vendor] of KEYWORD_VENDOR_RULES) { + if (re.test(value)) return vendor; + } + return undefined; +} + +/** + * LobeHub 品牌 token -> vendor slug(主力厂商关键词都未命中时的兜底)。 + * 归一少量(hailuo->minimax、jimeng->bytedance、kolors->kwaipilot、tiangong->skywork、 + * dbrx->databricks、yuanbao->tencent)。 + */ +const LOBEHUB_BRAND_VENDORS: Record = { + alephalpha: "alephalpha", + antgroup: "antgroup", + arcee: "arcee", + assemblyai: "assemblyai", + baichuan: "baichuan", + briaai: "briaai", + coqui: "coqui", + dbrx: "databricks", + elevenlabs: "elevenlabs", + essentialai: "essentialai", + fishaudio: "fishaudio", + hailuo: "minimax", + haiper: "haiper", + hedra: "hedra", + ideogram: "ideogram", + inflection: "inflection", + internlm: "internlm", + jimeng: "bytedance", + kolors: "kwaipilot", + kwaipilot: "kwaipilot", + llava: "llava", + luma: "luma", + microsoft: "microsoft", + midjourney: "midjourney", + myshell: "myshell", + nousresearch: "nousresearch", + novelai: "novelai", + openchat: "openchat", + pika: "pika", + pixverse: "pixverse", + reve: "reve", + runway: "runway", + rwkv: "rwkv", + sensenova: "sensenova", + skywork: "skywork", + stability: "stability", + suno: "suno", + tiangong: "skywork", + tripo: "tripo", + upstage: "upstage", + vidu: "vidu", + xuanyuan: "xuanyuan", + yandex: "yandex", + yuanbao: "tencent", +}; + +/** LobeHub 品牌 token 按长度降序(最长/最具体优先),供全名子串兜底扫描 */ +const LOBEHUB_BRAND_RULES: ReadonlyArray = Object.entries( + LOBEHUB_BRAND_VENDORS +).sort((a, b) => b[0].length - a[0].length); + +function lobehubBrandScan(value: string): string | undefined { + for (const [token, vendor] of LOBEHUB_BRAND_RULES) { + if (value.includes(token)) return vendor; + } + return undefined; +} + +export const UNKNOWN_VENDOR = "other"; + +/** + * 从模型调用名尽力推断 vendor slug。 + * ① Cloudflare "@cf//" 取 org(facebook->meta); + * ② "org/model" 且 org 非托管商时按 org 段; + * ③ 全名关键词扫描(抗托管商前缀污染); + * ④ bedrock 点前缀/dash 首段/整名 前缀映射; + * ⑤ LobeHub 品牌全集子串扫描;全失败 -> "other"。 + */ +export function inferVendorFromModelName(modelName: string): string { + const lower = modelName.trim().toLowerCase(); + if (!lower) return UNKNOWN_VENDOR; + + if (lower.startsWith("@cf/")) { + const parts = lower.split("/"); + const org = parts[1] ?? ""; + if (org === "facebook") return "meta"; + return ( + vendorOfPrefix(org) ?? + keywordScan(org) ?? + keywordScan(parts.slice(2).join("/")) ?? + UNKNOWN_VENDOR + ); + } + + const slash = lower.indexOf("/"); + if (slash >= 0) { + const org = lower.slice(0, slash); + if (!HOST_PREFIXES.has(org)) { + const byOrg = vendorOfPrefix(org) ?? keywordScan(org); + if (byOrg) return byOrg; + } + } + + const byKeyword = keywordScan(lower); + if (byKeyword) return byKeyword; + + const bare = slash >= 0 ? lower.slice(slash + 1) : lower; + const dot = /^([a-z0-9-]+)\./.exec(stripRegionPrefix(bare)); + if (dot) { + const byDot = vendorOfPrefix(dot[1]); + if (byDot) return byDot; + } + const dash = bare.indexOf("-"); + if (dash > 0) { + const byDash = vendorOfPrefix(bare.slice(0, dash)); + if (byDash) return byDash; + } + const byPrefix = vendorOfPrefix(bare); + if (byPrefix) return byPrefix; + + return lobehubBrandScan(lower) ?? UNKNOWN_VENDOR; +} + +/** + * 合成 vendor(无对应源 provider 条目)的兜底显示名。 + * 云端价格表 providers 字典有同名条目时以云端为准。 + */ +export const VENDOR_DISPLAY_NAMES: Record = { + other: "Other", + anthropic: "Anthropic", + openai: "OpenAI", + google: "Google", + meta: "Meta", + deepseek: "DeepSeek", + alibaba: "Alibaba", + mistral: "Mistral", + xai: "xAI", + cohere: "Cohere", + ai21: "AI21", + moonshotai: "Moonshot AI", + zhipuai: "Zhipu AI", + minimax: "MiniMax", + perplexity: "Perplexity", + stepfun: "StepFun", + baidu: "Baidu", + tencent: "Tencent", + bytedance: "ByteDance", + xiaomi: "Xiaomi", + "01-ai": "01.AI", + reka: "Reka", + nvidia: "NVIDIA", + ibm: "IBM", + liquid: "Liquid AI", + amazon: "Amazon", + inception: "Inception", + morph: "Morph", + "360": "360", + microsoft: "Microsoft", + iflytek: "iFlytek", + tii: "TII", + deepgram: "Deepgram", + jina: "Jina AI", + voyage: "Voyage AI", + baai: "BAAI", + bfl: "Black Forest Labs", + kling: "Kling", + recraft: "Recraft", + longcat: "LongCat", + alephalpha: "Aleph Alpha", + antgroup: "Ant Group", + arcee: "Arcee AI", + assemblyai: "AssemblyAI", + baichuan: "Baichuan", + briaai: "Bria AI", + coqui: "Coqui", + databricks: "Databricks", + elevenlabs: "ElevenLabs", + essentialai: "Essential AI", + fishaudio: "Fish Audio", + haiper: "Haiper", + hedra: "Hedra", + ideogram: "Ideogram", + inflection: "Inflection AI", + internlm: "InternLM", + kwaipilot: "Kwaipilot", + llava: "LLaVA", + luma: "Luma AI", + midjourney: "Midjourney", + myshell: "MyShell", + nousresearch: "Nous Research", + novelai: "NovelAI", + openchat: "OpenChat", + pika: "Pika", + pixverse: "PixVerse", + reve: "Reve", + runway: "Runway", + rwkv: "RWKV", + sensenova: "SenseNova", + skywork: "Skywork", + stability: "Stability AI", + suno: "Suno", + tripo: "Tripo", + upstage: "Upstage", + vidu: "Vidu", + xuanyuan: "XuanYuan", + yandex: "Yandex", +}; + +export function vendorDisplayName(vendorSlug: string): string { + return VENDOR_DISPLAY_NAMES[vendorSlug] ?? vendorSlug; +} diff --git a/src/lib/price-sync/cloud-price-table.ts b/src/lib/price-sync/cloud-price-table.ts index e24747726..289a7b1d8 100644 --- a/src/lib/price-sync/cloud-price-table.ts +++ b/src/lib/price-sync/cloud-price-table.ts @@ -1,8 +1,11 @@ import TOML from "@iarna/toml"; import type { ModelPriceData } from "@/types/model-price"; +import { type CptParseResult, parseCptTable } from "./cpt-schema"; -export const CLOUD_PRICE_TABLE_URL = "https://claude-code-hub.app/config/prices-base.toml"; -const FETCH_TIMEOUT_MS = 10000; +/** 云端价格表(CPT v1)地址;schema 见 https://cch-plus.com/pricing/v1/models.schema.json */ +export const CLOUD_PRICE_TABLE_URL = "https://cch-plus.com/pricing/v1/models.json"; +// 全量价格表约 10MB+,超时给足余量 +const FETCH_TIMEOUT_MS = 30000; export type CloudPriceTable = { metadata?: Record; @@ -15,6 +18,10 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } +/** + * 解析旧版 TOML 价格表(仅保留给用户本地上传旧文件的兼容路径, + * 云端同步已切换到 CPT v1 JSON)。 + */ export function parseCloudPriceTableToml(tomlText: string): CloudPriceTableResult { try { const parsed = TOML.parse(tomlText) as unknown; @@ -50,7 +57,8 @@ export function parseCloudPriceTableToml(tomlText: string): CloudPriceTableResul } } -export async function fetchCloudPriceTableToml( +/** 拉取云端 CPT v1 价格表原文(JSON 文本) */ +export async function fetchCloudPriceTableJson( url: string = CLOUD_PRICE_TABLE_URL ): Promise> { const expectedUrl = (() => { @@ -68,7 +76,7 @@ export async function fetchCloudPriceTableToml( const response = await fetch(url, { signal: controller.signal, headers: { - Accept: "text/plain", + Accept: "application/json", }, cache: "no-store", }); @@ -92,12 +100,12 @@ export async function fetchCloudPriceTableToml( return { ok: false, error: `云端价格表拉取失败:HTTP ${response.status}` }; } - const tomlText = await response.text(); - if (!tomlText.trim()) { + const jsonText = await response.text(); + if (!jsonText.trim()) { return { ok: false, error: "云端价格表拉取失败:内容为空" }; } - return { ok: true, data: tomlText }; + return { ok: true, data: jsonText }; } catch (error) { const message = error instanceof Error ? error.message : String(error); return { ok: false, error: `云端价格表拉取失败:${message}` }; @@ -105,3 +113,14 @@ export async function fetchCloudPriceTableToml( clearTimeout(timeoutId); } } + +/** 拉取并解析云端 CPT v1 价格表 */ +export async function fetchAndParseCloudPriceTable( + url: string = CLOUD_PRICE_TABLE_URL +): Promise { + const fetched = await fetchCloudPriceTableJson(url); + if (!fetched.ok) { + return fetched; + } + return parseCptTable(fetched.data); +} diff --git a/src/lib/price-sync/cloud-price-updater.ts b/src/lib/price-sync/cloud-price-updater.ts index 2bf1cb6ea..2187bec68 100644 --- a/src/lib/price-sync/cloud-price-updater.ts +++ b/src/lib/price-sync/cloud-price-updater.ts @@ -1,35 +1,39 @@ import { logger } from "@/lib/logger"; import type { PriceUpdateResult } from "@/types/model-price"; -import { - type CloudPriceTableResult, - fetchCloudPriceTableToml, - parseCloudPriceTableToml, -} from "./cloud-price-table"; +import { type CloudPriceTableResult, fetchAndParseCloudPriceTable } from "./cloud-price-table"; +import { type ConvertedCptTable, convertCptTable } from "./cpt-convert"; + +/** 拉取并转换云端 CPT v1 价格表 */ +export async function loadConvertedCloudPriceTable(): Promise< + CloudPriceTableResult +> { + const parsed = await fetchAndParseCloudPriceTable(); + if (!parsed.ok) { + return parsed; + } + try { + return { ok: true, data: convertCptTable(parsed.data) }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { ok: false, error: `云端价格表转换失败:${message}` }; + } +} /** - * 拉取云端 TOML 价格表并写入数据库(不覆盖 manual,本地优先)。 - * - * 说明: - * - 这里复用现有的批处理入库逻辑(processPriceTableInternal),以保持行为一致 - * - 任何失败都以 ok=false 返回,不抛出异常,避免影响调用方主流程 + * 将转换后的云端价格表写入数据库(source='cloud',本地 manual 优先), + * 并完成整表切换语义: + * - 未列入覆盖列表的 manual 模型跳过(记入 skippedConflicts) + * - 云端已下线的模型(不在本次表内的非 manual 行,含旧版 litellm 行)删除 + * - providers 字典 / vendor 汇总 / 版本指纹落入 cloud_pricing_catalog */ -export async function syncCloudPriceTableToDatabase( +export async function applyConvertedCloudPriceTable( + converted: ConvertedCptTable, overwriteManual?: string[] ): Promise> { - const tomlResult = await fetchCloudPriceTableToml(); - if (!tomlResult.ok) { - return tomlResult; - } - - const parseResult = parseCloudPriceTableToml(tomlResult.data); - if (!parseResult.ok) { - return { ok: false, error: parseResult.error }; - } - try { const { processPriceTableInternal } = await import("@/actions/model-prices"); - const jsonContent = JSON.stringify(parseResult.data.models); - const result = await processPriceTableInternal(jsonContent, overwriteManual); + const jsonContent = JSON.stringify(converted.models); + const result = await processPriceTableInternal(jsonContent, overwriteManual, "cloud"); if (!result.ok) { return { ok: false, error: result.error ?? "云端价格表写入失败" }; @@ -38,6 +42,35 @@ export async function syncCloudPriceTableToDatabase( return { ok: false, error: "云端价格表写入失败:返回结果为空" }; } + // 整表切换:清理云端已不存在的非 manual 模型(含旧版价格表遗留行) + try { + const { deleteCloudPricesNotIn } = await import("@/repository/model-price"); + const removed = await deleteCloudPricesNotIn(Object.keys(converted.models)); + if (removed > 0) { + logger.info("[PriceSync] Removed stale cloud price rows", { removed }); + } + } catch (error) { + logger.warn("[PriceSync] Failed to clean up stale cloud prices", { + error: error instanceof Error ? error.message : String(error), + }); + } + + try { + const { upsertCloudPricingCatalog } = await import("@/repository/cloud-pricing-catalog"); + await upsertCloudPricingCatalog({ + version: converted.version, + currency: converted.currency, + refreshedAt: converted.refreshedAt || null, + providers: converted.providers, + vendors: converted.vendors, + modelCount: Object.keys(converted.models).length, + }); + } catch (error) { + logger.warn("[PriceSync] Failed to persist cloud pricing catalog", { + error: error instanceof Error ? error.message : String(error), + }); + } + return { ok: true, data: result.data }; } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -45,6 +78,61 @@ export async function syncCloudPriceTableToDatabase( } } +/** + * 拉取云端价格表并写入数据库(不覆盖 manual,本地优先)。 + * + * 说明: + * - 版本指纹未变化且行数一致时跳过写库(表约 10MB/4000+ 模型,30 分钟一轮询) + * - 任何失败都以 ok=false 返回,不抛出异常,避免影响调用方主流程 + */ +export async function syncCloudPriceTableToDatabase( + overwriteManual?: string[] +): Promise> { + const loaded = await loadConvertedCloudPriceTable(); + if (!loaded.ok) { + return loaded; + } + const converted = loaded.data; + + // 版本短路:指纹一致且数据库云端行数与上次写入一致时无需重放整表 + if (!overwriteManual?.length && converted.version) { + try { + const [{ getCloudPricingCatalog }, { countCloudModelPrices }] = await Promise.all([ + import("@/repository/cloud-pricing-catalog"), + import("@/repository/model-price"), + ]); + const catalog = await getCloudPricingCatalog(); + if (catalog && catalog.version === converted.version) { + const cloudCount = await countCloudModelPrices(); + if (cloudCount === catalog.modelCount) { + const total = Object.keys(converted.models).length; + logger.debug("[PriceSync] Cloud price table unchanged, skipping write", { + version: converted.version, + total, + }); + return { + ok: true, + data: { + added: [], + updated: [], + unchanged: Object.keys(converted.models), + failed: [], + total, + skippedConflicts: [], + }, + }; + } + } + } catch (error) { + logger.debug("[PriceSync] Version short-circuit check failed, falling through", { + error: error instanceof Error ? error.message : String(error), + }); + } + } + + return applyConvertedCloudPriceTable(converted, overwriteManual); +} + const DEFAULT_THROTTLE_MS = 5 * 60 * 1000; /** diff --git a/src/lib/price-sync/cpt-convert.ts b/src/lib/price-sync/cpt-convert.ts new file mode 100644 index 000000000..51e33357c --- /dev/null +++ b/src/lib/price-sync/cpt-convert.ts @@ -0,0 +1,527 @@ +/** + * CPT v1 -> 内部 ModelPriceData 转换器。 + * + * 新云端价格表以「模型 x 多 provider 报价 x 价格轨道」组织,价格为 decimal 字符串 + * (per_M_tokens 等单位);内部计费管线使用 per-token 的 number 字段。本模块将 + * 每个模型转换为一条以裸模型名(canonical model_name)为键的 ModelPriceData: + * - 顶层字段来自默认报价(第一个 official 变体,否则第一个变体) + * - pricing 映射保留每个 provider 变体的转换结果(多来源价格选择/对比/固化用) + * - tracks 中可识别的分层(>200K / >272K / priority 服务档)映射为既有分层字段 + */ +import { iconFileForVendor, type VendorIconFileEntry } from "@/lib/model-vendor/vendor-icon-files"; +import { vendorDisplayName } from "@/lib/model-vendor/vendor-inference"; +import type { ModelPriceData } from "@/types/model-price"; +import type { + CptCharge, + CptModelEntry, + CptPricingVariant, + CptProviderInfo, + CptTable, + CptTrack, + CptTrackTrigger, +} from "./cpt-schema"; + +const MILLION = 1_000_000; + +/** 追踪阈值的容差归一:200000/200001 视为 200K,272000/272001 视为 272K */ +const TIER_200K_MIN = 200000; +const TIER_200K_MAX = 200001; +const TIER_272K_MIN = 272000; +const TIER_272K_MAX = 272001; + +export interface CloudVendorSummary { + vendor: string; + name: string; + icon?: string; + iconMono?: boolean; + modelCount: number; +} + +export interface ConvertedCptTable { + models: Record; + vendors: CloudVendorSummary[]; + providers: Record; + version: string; + currency: string; + refreshedAt: string; +} + +function parseDecimal(value: string | undefined): number | null { + if (typeof value !== "string" || !value.trim()) return null; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +} + +/** 浮点乘算后收敛精度,避免 0.1*3 之类的长尾污染存储 */ +function roundPrecision(value: number): number { + if (!Number.isFinite(value)) return 0; + if (value === 0) return 0; + return Number(value.toPrecision(12)); +} + +type ChargeFieldTarget = { kind: "per_token"; field: string } | { kind: "scalar"; field: string }; + +/** per_M_tokens 计费维度 -> 内部 per-token 字段 */ +const TOKEN_CHARGE_FIELDS: Record = { + prompt: "input_cost_per_token", + completion: "output_cost_per_token", + cache_read: "cache_read_input_token_cost", + cache_write: "cache_creation_input_token_cost", + cache_write_1h: "cache_creation_input_token_cost_above_1hr", +}; + +/** 分层轨道可映射的计费维度 -> 200K/272K 字段名 */ +const TIER_FIELD_BY_CHARGE: Record = { + prompt: { + above200k: "input_cost_per_token_above_200k_tokens", + above272k: "input_cost_per_token_above_272k_tokens", + }, + completion: { + above200k: "output_cost_per_token_above_200k_tokens", + above272k: "output_cost_per_token_above_272k_tokens", + }, + cache_read: { + above200k: "cache_read_input_token_cost_above_200k_tokens", + above272k: "cache_read_input_token_cost_above_272k_tokens", + }, + cache_write: { + above200k: "cache_creation_input_token_cost_above_200k_tokens", + above272k: "cache_creation_input_token_cost_above_272k_tokens", + }, + cache_write_1h: { + above200k: "cache_creation_input_token_cost_above_1hr_above_200k_tokens", + above272k: "cache_creation_input_token_cost_above_1hr_above_272k_tokens", + }, +}; + +/** priority 服务档可映射的计费维度 -> priority 字段名(含分层组合) */ +const PRIORITY_FIELD_BY_CHARGE: Record< + string, + { base: string; above200k?: string; above272k?: string } +> = { + prompt: { + base: "input_cost_per_token_priority", + above200k: "input_cost_per_token_above_200k_tokens_priority", + above272k: "input_cost_per_token_above_272k_tokens_priority", + }, + completion: { + base: "output_cost_per_token_priority", + above200k: "output_cost_per_token_above_200k_tokens_priority", + above272k: "output_cost_per_token_above_272k_tokens_priority", + }, + cache_read: { + base: "cache_read_input_token_cost_priority", + above200k: "cache_read_input_token_cost_above_200k_tokens_priority", + above272k: "cache_read_input_token_cost_above_272k_tokens_priority", + }, +}; + +function chargeTarget(chargeKey: string, charge: CptCharge): ChargeFieldTarget | null { + if (charge.unit === "per_M_tokens") { + const tokenField = TOKEN_CHARGE_FIELDS[chargeKey]; + if (tokenField) return { kind: "per_token", field: tokenField }; + if (chargeKey === "image_input") + return { kind: "per_token", field: "input_cost_per_image_token" }; + if (chargeKey === "image_output") { + return { kind: "per_token", field: "output_cost_per_image_token" }; + } + return null; + } + if (charge.unit === "per_image") { + if (chargeKey === "image_input") return { kind: "scalar", field: "input_cost_per_image" }; + if (chargeKey === "image_output" || chargeKey.startsWith("image_output")) { + // 仅取无尺寸后缀的基础档;带尺寸变体(image_output_1024x1536 等)跳过 + if (chargeKey !== "image_output") return null; + return { kind: "scalar", field: "output_cost_per_image" }; + } + return null; + } + if (charge.unit === "per_request" && chargeKey === "request") { + return { kind: "scalar", field: "input_cost_per_request" }; + } + return null; +} + +/** track factor 解析:charge_factors 覆盖默认 factor */ +function trackFactorFor(track: CptTrack, chargeKey: string): number | null { + const explicit = track.charge_factors?.[chargeKey]; + if (explicit !== undefined) return parseDecimal(explicit); + return parseDecimal(track.factor); +} + +type TrackClass = + | { kind: "default" } + | { kind: "tier"; tier: "200k" | "272k" } + | { kind: "priority" } + | { kind: "priority_tier"; tier: "200k" | "272k" } + | { kind: "unsupported" }; + +function classifyThreshold(threshold: number | undefined): "200k" | "272k" | null { + if (typeof threshold !== "number") return null; + if (threshold >= TIER_200K_MIN && threshold <= TIER_200K_MAX) return "200k"; + if (threshold >= TIER_272K_MIN && threshold <= TIER_272K_MAX) return "272k"; + return null; +} + +function isPriorityTrigger(trigger: CptTrackTrigger): boolean { + return ( + trigger.kind === "body_matches" && + trigger.field === "service_tier" && + typeof trigger.pattern === "string" && + /priority/.test(trigger.pattern) + ); +} + +/** Claude 1M beta 等长上下文轨道的 header 触发条件(辅助条件,不影响分层归类) */ +function isLongContextHeaderTrigger(trigger: CptTrackTrigger): boolean { + return trigger.kind === "header_matches"; +} + +function classifyTrack(track: CptTrack): TrackClass { + const triggers = Array.isArray(track.triggers) ? track.triggers : []; + if (triggers.length === 0) return { kind: "default" }; + + let tier: "200k" | "272k" | null = null; + let priority = false; + + for (const trigger of triggers) { + if (trigger.kind === "input_tokens_above") { + const classified = classifyThreshold(trigger.threshold); + if (!classified) return { kind: "unsupported" }; + tier = classified; + continue; + } + if (isPriorityTrigger(trigger)) { + priority = true; + continue; + } + if (isLongContextHeaderTrigger(trigger)) { + // 长上下文 beta header(如 anthropic-beta: context-1m-*)与 tokens 阈值组合出现, + // 计费侧由 context1mApplied 标志控制,这里按分层轨道归类即可 + continue; + } + return { kind: "unsupported" }; + } + + if (tier && priority) return { kind: "priority_tier", tier }; + if (tier) return { kind: "tier", tier }; + if (priority) return { kind: "priority" }; + return { kind: "unsupported" }; +} + +/** + * 转换单个 provider 报价变体为内部价格字段集合。 + * 返回 null 表示该变体没有任何可识别的计费字段。 + */ +export function convertCptVariant(variant: CptPricingVariant): Record | null { + const charges = variant.charges ?? {}; + const tracks = Array.isArray(variant.tracks) ? variant.tracks : []; + const defaultTrack = tracks.find( + (track) => !Array.isArray(track.triggers) || track.triggers.length === 0 + ); + + const node: Record = {}; + let hasBillableField = false; + + const basePriceOf = (chargeKey: string): number | null => { + const charge = charges[chargeKey]; + if (!charge) return null; + return parseDecimal(charge.price); + }; + + // 基础价:base price x 默认轨道 factor(无默认轨道时 factor=1) + for (const [chargeKey, charge] of Object.entries(charges)) { + if (!charge || typeof charge !== "object") continue; + // 币种覆盖的报价(如 CNY)与内部 USD 计费不可比,跳过该维度 + if (typeof charge.currency === "string" && charge.currency && charge.currency !== "USD") { + continue; + } + const target = chargeTarget(chargeKey, charge); + const price = parseDecimal(charge.price); + if (!target || price === null || price < 0) continue; + + const factor = defaultTrack ? trackFactorFor(defaultTrack, chargeKey) : 1; + const effective = price * (factor ?? 1); + const value = + target.kind === "per_token" ? roundPrecision(effective / MILLION) : roundPrecision(effective); + node[target.field] = value; + hasBillableField = true; + } + + // web_search(per_k_calls)-> 每次查询成本,保持与旧格式 search_context_cost_per_query 兼容 + const webSearch = charges.web_search; + if (webSearch?.unit === "per_k_calls") { + const price = parseDecimal(webSearch.price); + if (price !== null && price >= 0) { + const perQuery = roundPrecision(price / 1000); + node.search_context_cost_per_query = { + search_context_size_low: perQuery, + search_context_size_medium: perQuery, + search_context_size_high: perQuery, + }; + hasBillableField = true; + } + } + + const fileSearch = charges.file_search_call ?? charges.file_search; + if (fileSearch?.unit === "per_k_calls") { + const price = parseDecimal(fileSearch.price); + if (price !== null && price >= 0) { + node.file_search_cost_per_1k_calls = roundPrecision(price); + } + } + + // 分层/priority 轨道:base price x 轨道 factor + for (const track of tracks) { + const classified = classifyTrack(track); + if (classified.kind === "default" || classified.kind === "unsupported") continue; + + if (classified.kind === "tier" || classified.kind === "priority_tier") { + const isPriority = classified.kind === "priority_tier"; + for (const [chargeKey, fields] of Object.entries(TIER_FIELD_BY_CHARGE)) { + const basePrice = basePriceOf(chargeKey); + if (basePrice === null) continue; + const factor = trackFactorFor(track, chargeKey); + if (factor === null || factor < 0) continue; + + const field = isPriority + ? classified.tier === "200k" + ? PRIORITY_FIELD_BY_CHARGE[chargeKey]?.above200k + : PRIORITY_FIELD_BY_CHARGE[chargeKey]?.above272k + : classified.tier === "200k" + ? fields.above200k + : fields.above272k; + if (!field) continue; + + node[field] = roundPrecision((basePrice * factor) / MILLION); + hasBillableField = true; + } + continue; + } + + // priority 服务档(不带 tokens 阈值) + for (const [chargeKey, fields] of Object.entries(PRIORITY_FIELD_BY_CHARGE)) { + const basePrice = basePriceOf(chargeKey); + if (basePrice === null) continue; + const factor = trackFactorFor(track, chargeKey); + if (factor === null || factor < 0) continue; + node[fields.base] = roundPrecision((basePrice * factor) / MILLION); + hasBillableField = true; + } + } + + if (!hasBillableField) return null; + return node; +} + +const CAPABILITY_FIELD_MAP: Record = { + assistant_prefill: ["supports_assistant_prefill"], + computer_use: ["supports_computer_use"], + function_calling: ["supports_function_calling", "supports_tool_choice"], + pdf_input: ["supports_pdf_input"], + prompt_caching: ["supports_prompt_caching"], + reasoning: ["supports_reasoning"], + structured_output: ["supports_response_schema"], + vision: ["supports_vision"], + audio_input: ["supports_audio_input"], + audio_output: ["supports_audio_output"], + video_input: ["supports_video_input"], + web_search: ["supports_web_search"], +}; + +function modeOfModelType(modelType: string | null | undefined): string { + if (!modelType) return "chat"; + switch (modelType) { + case "chat": + return "chat"; + case "completion": + return "completion"; + case "responses": + return "responses"; + case "image": + case "image_generation": + return "image_generation"; + default: + return modelType; + } +} + +function variantPricingKey(variant: CptPricingVariant): string { + const region = typeof variant.region === "string" && variant.region ? `@${variant.region}` : ""; + return `${variant.provider}${region}`; +} + +function resolveVendorIcon( + vendor: string, + providers: Record +): VendorIconFileEntry | null { + const provider = providers[vendor]; + if (provider?.icon) { + return { file: provider.icon, mono: provider.icon_mono === true }; + } + return iconFileForVendor(vendor); +} + +const MAX_ALIASES_PER_MODEL = 64; + +/** + * 转换单个模型条目。返回 null 表示所有报价变体都无可计费字段。 + */ +export function convertCptModelEntry( + entry: CptModelEntry, + providers: Record +): ModelPriceData | null { + const variants = Array.isArray(entry.pricing) ? entry.pricing : []; + const pricingMap: Record> = {}; + const officialKeys: string[] = []; + + let defaultKey: string | null = null; + let defaultNode: Record | null = null; + + for (const variant of variants) { + if (!variant || typeof variant.provider !== "string" || !variant.provider) continue; + const converted = convertCptVariant(variant); + if (!converted) continue; + + const key = variantPricingKey(variant); + const node: Record = { ...converted }; + if (variant.official === true) { + node.official = true; + } + if (typeof variant.provider_model_id === "string" && variant.provider_model_id) { + node.provider_model_id = variant.provider_model_id; + } + pricingMap[key] = node; + + if (variant.official === true) { + officialKeys.push(key); + if (!defaultKey) { + defaultKey = key; + defaultNode = converted; + } + } + } + + const pricingKeys = Object.keys(pricingMap); + if (pricingKeys.length === 0) return null; + + if (!defaultKey) { + defaultKey = pricingKeys[0]; + defaultNode = { ...pricingMap[defaultKey] }; + delete defaultNode.official; + delete defaultNode.provider_model_id; + } + + const vendorIcon = resolveVendorIcon(entry.vendor, providers); + + const priceData: ModelPriceData = { + ...(defaultNode as Partial), + mode: modeOfModelType(entry.model_type) as ModelPriceData["mode"], + display_name: entry.display_name || entry.model_name, + vendor: entry.vendor, + slug: entry.slug, + providers: pricingKeys, + pricing: pricingMap, + official_pricing_provider: officialKeys[0] ?? null, + }; + delete (priceData as Record).official; + delete (priceData as Record).provider_model_id; + + if (vendorIcon) { + priceData.vendor_icon = vendorIcon.file; + if (vendorIcon.mono) priceData.vendor_icon_mono = true; + } + + if (Array.isArray(entry.aliases) && entry.aliases.length > 0) { + const aliases = entry.aliases + .filter((alias) => typeof alias === "string" && alias.trim() && alias !== entry.model_name) + .slice(0, MAX_ALIASES_PER_MODEL); + if (aliases.length > 0) { + priceData.aliases = aliases; + } + } + + if (typeof entry.family === "string" && entry.family) { + priceData.model_family = entry.family; + } + if (typeof entry.max_input_tokens === "number") { + priceData.max_input_tokens = entry.max_input_tokens; + } + if (typeof entry.max_output_tokens === "number") { + priceData.max_output_tokens = entry.max_output_tokens; + priceData.max_tokens = entry.max_output_tokens; + } + if (entry.deprecated === true) { + priceData.deprecated = true; + } + if (typeof entry.knowledge_cutoff === "string" && entry.knowledge_cutoff) { + priceData.knowledge_cutoff = entry.knowledge_cutoff; + } + + if (entry.capabilities && typeof entry.capabilities === "object") { + for (const [capability, fields] of Object.entries(CAPABILITY_FIELD_MAP)) { + if (entry.capabilities[capability] === true) { + for (const field of fields) { + (priceData as Record)[field] = true; + } + } + } + } + + return priceData; +} + +/** bare 模型名冲突时的择优:官方报价 > 非 other vendor > 报价变体多者 */ +function preferEntry(a: CptModelEntry, b: CptModelEntry): CptModelEntry { + const officialA = a.pricing?.some((variant) => variant?.official === true) ?? false; + const officialB = b.pricing?.some((variant) => variant?.official === true) ?? false; + if (officialA !== officialB) return officialA ? a : b; + if ((a.vendor === "other") !== (b.vendor === "other")) return a.vendor === "other" ? b : a; + return (b.pricing?.length ?? 0) > (a.pricing?.length ?? 0) ? b : a; +} + +/** + * 转换整张 CPT 价格表。 + * models 以 canonical bare model_name 为键(与内部 model_prices.model_name 对齐)。 + */ +export function convertCptTable(table: CptTable): ConvertedCptTable { + const entryByName = new Map(); + for (const entry of table.models) { + const name = entry.model_name.trim(); + if (!name) continue; + const existing = entryByName.get(name); + entryByName.set(name, existing ? preferEntry(existing, entry) : entry); + } + + const models: Record = Object.create(null); + const vendorCounts = new Map(); + + for (const [name, entry] of entryByName) { + if (name === "__proto__" || name === "constructor" || name === "prototype") continue; + const converted = convertCptModelEntry(entry, table.providers); + if (!converted) continue; + models[name] = converted; + vendorCounts.set(entry.vendor, (vendorCounts.get(entry.vendor) ?? 0) + 1); + } + + const vendors: CloudVendorSummary[] = Array.from(vendorCounts.entries()) + .map(([vendor, modelCount]) => { + const icon = resolveVendorIcon(vendor, table.providers); + return { + vendor, + name: table.providers[vendor]?.name ?? vendorDisplayName(vendor), + ...(icon ? { icon: icon.file, iconMono: icon.mono === true } : {}), + modelCount, + }; + }) + .sort((a, b) => b.modelCount - a.modelCount || a.vendor.localeCompare(b.vendor)); + + return { + models, + vendors, + providers: table.providers, + version: table.version, + currency: table.currency, + refreshedAt: table.refreshed_at, + }; +} diff --git a/src/lib/price-sync/cpt-schema.ts b/src/lib/price-sync/cpt-schema.ts new file mode 100644 index 000000000..5f4e2ba6e --- /dev/null +++ b/src/lib/price-sync/cpt-schema.ts @@ -0,0 +1,176 @@ +/** + * CCHP Cloud Pricing Table (CPT) v1 schema types and parser. + * + * Source: https://cch-plus.com/pricing/v1/models.json + * Schema: https://cch-plus.com/pricing/v1/models.schema.json + * + * All prices are decimal strings to avoid float ambiguity; the converter + * (cpt-convert.ts) parses them into per-token numbers for internal billing. + */ + +export const CPT_SCHEMA_ID = "cchp.pricing-table/v1"; + +export type CptChargeUnit = + | "per_M_characters" + | "per_M_tokens" + | "per_M_tokens_per_hour" + | "per_image" + | "per_k_calls" + | "per_request" + | "per_second"; + +export interface CptCharge { + price: string; + unit: CptChargeUnit; + currency?: string; +} + +export interface CptTrackTrigger { + kind: "body_matches" | "endpoint_matches" | "header_matches" | "input_tokens_above"; + field?: string; + header?: string; + pattern?: string; + threshold?: number; + inclusive?: boolean; +} + +export interface CptTrack { + label: string; + factor: string; + charge_factors?: Record; + triggers: CptTrackTrigger[]; +} + +export interface CptPricingVariant { + provider: string; + official: boolean; + source: string; + provider_model_id?: string; + region?: string | null; + charges: Record; + tracks?: CptTrack[] | null; + finetune_charges?: Record; +} + +export interface CptModelEntry { + slug: string; + model_name: string; + vendor: string; + display_name: string; + aliases?: string[]; + family?: string; + model_type?: string | null; + intro?: string; + intro_i18n?: Record; + knowledge_cutoff?: string; + released_at?: string; + deprecated?: boolean; + deprecation_date?: string; + status?: string; + docs_url?: string; + max_input_tokens?: number; + max_output_tokens?: number; + capabilities?: Record; + modalities?: { input?: string[]; output?: string[] }; + pricing: CptPricingVariant[]; + rate_limits?: { rpm?: number; tpm?: number }; + reasoning_config?: { budget_min?: number; mandatory?: boolean }; + benchmarks?: Record; +} + +export interface CptProviderInfo { + name: string; + doc?: string; + icon?: string; + icon_mono?: boolean; +} + +export interface CptTable { + schema: typeof CPT_SCHEMA_ID; + version: string; + currency: string; + refreshed_at: string; + models: CptModelEntry[]; + providers: Record; +} + +export type CptParseResult = { ok: true; data: CptTable } | { ok: false; error: string }; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** 判断一个已解析的 JSON 值是否为 CPT v1 价格表(用于上传格式嗅探) */ +export function isCptTableLike(value: unknown): value is Record { + return isRecord(value) && value.schema === CPT_SCHEMA_ID && Array.isArray(value.models); +} + +/** + * 解析并校验 CPT v1 价格表 JSON 文本。 + * 只做结构级校验(必填字段/类型),字段内容的健壮性由转换器兜底。 + */ +export function parseCptTable(jsonText: string): CptParseResult { + let parsed: unknown; + try { + parsed = JSON.parse(jsonText); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { ok: false, error: `价格表 JSON 解析失败: ${message}` }; + } + return parseCptTableValue(parsed); +} + +/** 同 parseCptTable,但接受已解析的 JSON 值(用于上传路径复用) */ +export function parseCptTableValue(parsed: unknown): CptParseResult { + if (!isRecord(parsed)) { + return { ok: false, error: "价格表格式无效:根节点不是对象" }; + } + + if (parsed.schema !== CPT_SCHEMA_ID) { + return { + ok: false, + error: `价格表格式无效:schema 不是 ${CPT_SCHEMA_ID}(实际为 ${String(parsed.schema)})`, + }; + } + + if (!Array.isArray(parsed.models)) { + return { ok: false, error: "价格表格式无效:缺少 models 数组" }; + } + + if (!isRecord(parsed.providers)) { + return { ok: false, error: "价格表格式无效:缺少 providers 字典" }; + } + + const models: CptModelEntry[] = []; + for (const entry of parsed.models) { + if (!isRecord(entry)) continue; + if (typeof entry.model_name !== "string" || !entry.model_name.trim()) continue; + if (typeof entry.slug !== "string" || !entry.slug.trim()) continue; + if (typeof entry.vendor !== "string" || !entry.vendor.trim()) continue; + if (!Array.isArray(entry.pricing)) continue; + models.push(entry as unknown as CptModelEntry); + } + + if (models.length === 0) { + return { ok: false, error: "价格表格式无效:models 为空" }; + } + + const providers: Record = Object.create(null); + for (const [slug, info] of Object.entries(parsed.providers)) { + if (slug === "__proto__" || slug === "constructor" || slug === "prototype") continue; + if (!isRecord(info) || typeof info.name !== "string") continue; + providers[slug] = info as unknown as CptProviderInfo; + } + + return { + ok: true, + data: { + schema: CPT_SCHEMA_ID, + version: typeof parsed.version === "string" ? parsed.version : "", + currency: typeof parsed.currency === "string" ? parsed.currency : "USD", + refreshed_at: typeof parsed.refreshed_at === "string" ? parsed.refreshed_at : "", + models, + providers, + }, + }; +} diff --git a/src/lib/public-status/config-publisher.ts b/src/lib/public-status/config-publisher.ts index b19065c19..a479970c2 100644 --- a/src/lib/public-status/config-publisher.ts +++ b/src/lib/public-status/config-publisher.ts @@ -102,9 +102,11 @@ export async function publishCurrentPublicStatusConfigProjection(input: { vendorIconKey: resolvePublicStatusVendorIconKey({ modelName, vendorIconKey: - typeof price?.priceData.litellm_provider === "string" - ? price.priceData.litellm_provider - : undefined, + typeof price?.priceData.vendor === "string" + ? price.priceData.vendor + : typeof price?.priceData.litellm_provider === "string" + ? price.priceData.litellm_provider + : undefined, providerTypeOverride: model.providerTypeOverride, }), requestTypeBadge: resolveRequestTypeBadge(modelName, model.providerTypeOverride), @@ -135,9 +137,11 @@ export async function publishCurrentPublicStatusConfigProjection(input: { vendorIconKey: resolvePublicStatusVendorIconKey({ modelName, vendorIconKey: - typeof price?.priceData.litellm_provider === "string" - ? price.priceData.litellm_provider - : undefined, + typeof price?.priceData.vendor === "string" + ? price.priceData.vendor + : typeof price?.priceData.litellm_provider === "string" + ? price.priceData.litellm_provider + : undefined, providerTypeOverride: model.providerTypeOverride, }), requestTypeBadge: resolveRequestTypeBadge(modelName, model.providerTypeOverride), diff --git a/src/lib/public-status/vendor-icon-key.ts b/src/lib/public-status/vendor-icon-key.ts index b283d5e11..28c6b0c9d 100644 --- a/src/lib/public-status/vendor-icon-key.ts +++ b/src/lib/public-status/vendor-icon-key.ts @@ -1,4 +1,4 @@ -import { getModelVendor } from "@/lib/model-vendor-rules"; +import { inferVendorFromModelName, UNKNOWN_VENDOR } from "@/lib/model-vendor/vendor-inference"; import type { ProviderType } from "@/types/provider"; export const PUBLIC_STATUS_VENDOR_ICON_KEYS = [ @@ -70,39 +70,51 @@ const RAW_PROVIDER_TO_PUBLIC_STATUS_ICON_KEY: Record 公开状态页 icon key const MODEL_VENDOR_TO_PUBLIC_STATUS_ICON_KEY: Record = { anthropic: "anthropic", azure: "azure", baichuan: "baichuan", bedrock: "bedrock", + amazon: "bedrock", cohere: "cohere", deepseek: "deepseek", - gemma: "gemma", + google: "gemini", groq: "groq", - hunyuan: "hunyuan", + tencent: "hunyuan", internlm: "internlm", - kimi: "kimi", meta: "meta", minimax: "minimax", mistral: "mistral", - moonshot: "moonshot", + moonshotai: "moonshot", nvidia: "nvidia", ollama: "ollama", openai: "openai", openrouter: "openrouter", perplexity: "perplexity", - qwen: "qwen", + alibaba: "qwen", sensenova: "sensenova", - spark: "spark", + iflytek: "spark", stepfun: "stepfun", together: "together", - vertex: "gemini", - volcengine: "volcengine", - wenxin: "wenxin", + bytedance: "volcengine", + baidu: "wenxin", xai: "xai", - yi: "yi", + "01-ai": "yi", zhipuai: "zhipuai", }; @@ -144,9 +156,9 @@ export function resolvePublicStatusVendorIconKey(input: { return explicitKey; } - const matchedVendor = getModelVendor(input.modelName); - if (matchedVendor) { - const normalizedKey = MODEL_VENDOR_TO_PUBLIC_STATUS_ICON_KEY[matchedVendor.i18nKey]; + const inferredVendor = inferVendorFromModelName(input.modelName); + if (inferredVendor !== UNKNOWN_VENDOR) { + const normalizedKey = MODEL_VENDOR_TO_PUBLIC_STATUS_ICON_KEY[inferredVendor]; if (normalizedKey) { return normalizedKey; } diff --git a/src/lib/utils/model-name-matching.ts b/src/lib/utils/model-name-matching.ts new file mode 100644 index 000000000..1ea340629 --- /dev/null +++ b/src/lib/utils/model-name-matching.ts @@ -0,0 +1,59 @@ +import { isHostPrefix, stripRegionPrefix } from "@/lib/model-vendor/vendor-inference"; + +/** openrouter 等网关追加的调用尾缀(":free"、":thinking" 等),匹配时剥除 */ +const CALL_SUFFIX_RE = /:(free|thinking|extended|online|nitro|floor|exacto)$/i; + +function pushUnique(list: string[], value: string, exclude: string) { + const candidate = value.trim(); + if (!candidate || candidate === exclude) return; + if (!list.includes(candidate)) list.push(candidate); +} + +/** + * 生成模型名的回退匹配候选(不含原名),按优先级排列。 + * 处理三类偏差: + * - "vendor/model" 或 "host/org/model" 带斜杠调用名 -> 去前缀的裸名 + * - bedrock 风格区域/厂商点前缀("us.anthropic.claude-*") + * - 网关调用尾缀(":thinking" / ":free" 等) + */ +export function buildModelNameFallbackCandidates(modelName: string): string[] { + const original = modelName.trim(); + if (!original) return []; + + const candidates: string[] = []; + const seeds = new Set([original]); + + const noSuffix = original.replace(CALL_SUFFIX_RE, ""); + seeds.add(noSuffix); + + for (const seed of Array.from(seeds)) { + // "org/model":org 为托管商时跳过 org;否则同时保留完整段与最后一段 + if (seed.includes("/")) { + const firstSlash = seed.indexOf("/"); + const org = seed.slice(0, firstSlash); + const rest = seed.slice(firstSlash + 1); + if (isHostPrefix(org)) { + seeds.add(rest); + } + const lastSegment = seed.slice(seed.lastIndexOf("/") + 1); + seeds.add(lastSegment); + seeds.add(rest); + } + } + + for (const seed of seeds) { + const stripped = stripRegionPrefix(seed); + if (stripped !== seed) seeds.add(stripped); + } + + // 输出顺序:去尾缀原名 -> 去托管前缀 -> 最后一段 -> 区域前缀剥离 -> 小写变体 + pushUnique(candidates, noSuffix, original); + for (const seed of seeds) { + pushUnique(candidates, seed, original); + } + for (const seed of [original, ...candidates]) { + pushUnique(candidates, seed.toLowerCase(), original); + } + + return candidates; +} diff --git a/src/lib/utils/pricing-resolution.ts b/src/lib/utils/pricing-resolution.ts index 4f3f7d4e0..115e75032 100644 --- a/src/lib/utils/pricing-resolution.ts +++ b/src/lib/utils/pricing-resolution.ts @@ -6,6 +6,7 @@ export type ResolvedPricingSource = | "local_manual" | "cloud_exact" | "cloud_model_fallback" + | "cloud_official" | "priority_fallback" | "single_provider_top_level" | "official_fallback"; @@ -102,10 +103,30 @@ function extractHost(urlValue: string | null | undefined): string { } } +/** + * vendor -> 视为"官方价"的 provider key 集合。 + * 与云端价格表生成侧的 OFFICIAL_PROVIDER_EXTRA 对齐:与 vendor 同名的 provider 即官方, + * 此表登记额外的官方渠道(如 Google 的 gemini API 与 Vertex 都是第一方价)。 + */ +const OFFICIAL_PROVIDER_EXTRA: Record = { + google: ["google-vertex"], + amazon: ["amazon-bedrock"], + alibaba: ["qwen"], + zhipuai: ["z-ai"], + bytedance: ["volcengine"], + meta: ["llama"], +}; + function getOfficialProviderKeys( modelName: string | null | undefined, priceData?: ModelPriceData ): string[] { + // 云端价格表带 vendor 字段时,官方 provider 由数据侧决定 + const vendor = normalizeText(typeof priceData?.vendor === "string" ? priceData.vendor : ""); + if (vendor) { + return [vendor, ...(OFFICIAL_PROVIDER_EXTRA[vendor] ?? [])]; + } + const family = normalizeText( typeof priceData?.model_family === "string" ? priceData.model_family : "" ); @@ -125,7 +146,7 @@ function getOfficialProviderKeys( } if (family.includes("gemini") || normalizedModelName.startsWith("gemini")) { - return ["vertex_ai", "vertex", "google"]; + return ["google", "google-vertex", "vertex_ai", "vertex"]; } return []; @@ -167,9 +188,43 @@ export function resolvePricingKeyCandidates( pushUnique(candidates, "anthropic", "exact"); } if (name.includes("vertex") || host.includes("googleapis.com") || name.includes("google")) { + pushUnique(candidates, "google", "exact"); + pushUnique(candidates, "google-vertex", "exact"); pushUnique(candidates, "vertex_ai", "exact"); pushUnique(candidates, "vertex", "exact"); - pushUnique(candidates, "google", "exact"); + } + if (name.includes("bedrock") || host.includes("amazonaws.com")) { + pushUnique(candidates, "amazon-bedrock", "exact"); + pushUnique(candidates, "bedrock", "exact"); + } + if (name.includes("deepseek") || host.includes("deepseek.com")) { + pushUnique(candidates, "deepseek", "exact"); + } + if (name.includes("moonshot") || name.includes("kimi") || host.includes("moonshot")) { + pushUnique(candidates, "moonshotai", "exact"); + } + if (name.includes("siliconflow") || host.includes("siliconflow")) { + pushUnique(candidates, "siliconflow", "exact"); + } + if (name.includes("volcengine") || name.includes("doubao") || host.includes("volces.com")) { + pushUnique(candidates, "volcengine", "exact"); + } + if (name.includes("dashscope") || host.includes("dashscope") || host.includes("aliyun")) { + pushUnique(candidates, "alibaba", "exact"); + pushUnique(candidates, "alibaba-cn", "exact"); + } + if (name.includes("groq") || host.includes("groq.com")) { + pushUnique(candidates, "groq", "exact"); + } + if (name.includes("xai") || name.includes("grok") || host.includes("x.ai")) { + pushUnique(candidates, "xai", "exact"); + } + if (name.includes("mistral") || host.includes("mistral.ai")) { + pushUnique(candidates, "mistral", "exact"); + } + if (name.includes("zhipu") || name.includes("bigmodel") || host.includes("bigmodel.cn")) { + pushUnique(candidates, "zhipuai", "exact"); + pushUnique(candidates, "z-ai", "exact"); } for (const officialKey of getOfficialProviderKeys(modelName, priceData)) { @@ -321,13 +376,58 @@ function resolveFromPricingMap( return null; } +/** + * 云端价格表数据驱动的官方价选择: + * 优先 official_pricing_provider 指名的节点,其次任意 official=true 的节点。 + */ +function resolveCloudOfficial(candidate: ModelRecordCandidate): ResolvedPricing | null { + const pricingMap = getPricingMap(candidate.record); + if (!candidate.record || !pricingMap) { + return null; + } + + const declaredKey = candidate.record.priceData.official_pricing_provider; + const officialKeys: string[] = []; + if (typeof declaredKey === "string" && declaredKey && pricingMap[declaredKey]) { + officialKeys.push(declaredKey); + } + for (const [key, node] of Object.entries(pricingMap)) { + if (node?.official === true && !officialKeys.includes(key)) { + officialKeys.push(key); + } + } + + for (const key of officialKeys) { + const pricingNode = pricingMap[key]; + if (!pricingNode) continue; + const mergedPriceData = mergePriceData(candidate.record.priceData, pricingNode, key); + if (!hasValidPriceData(mergedPriceData)) continue; + + return { + resolvedModelName: candidate.modelName ?? candidate.record.modelName, + resolvedPricingProviderKey: key, + source: "cloud_official", + priceData: mergedPriceData, + pricingNode, + }; + } + + return null; +} + function resolveDetailedFallback(candidate: ModelRecordCandidate): ResolvedPricing | null { const pricingMap = getPricingMap(candidate.record); if (!candidate.record || !pricingMap) { return null; } - const keys = Object.keys(pricingMap).sort((a, b) => compareDetailKeys(a, b, pricingMap)); + // 官方节点优先,再按明细字段数排序 + const keys = Object.keys(pricingMap).sort((a, b) => { + const officialA = pricingMap[a]?.official === true ? 0 : 1; + const officialB = pricingMap[b]?.official === true ? 0 : 1; + if (officialA !== officialB) return officialA - officialB; + return compareDetailKeys(a, b, pricingMap); + }); const selectedKey = keys[0]; if (!selectedKey) { return null; @@ -359,6 +459,8 @@ function resolveTopLevel(candidate: ModelRecordCandidate): ResolvedPricing | nul candidate.record.priceData.selected_pricing_provider.trim()) || (typeof candidate.record.priceData.litellm_provider === "string" && candidate.record.priceData.litellm_provider.trim()) || + (typeof candidate.record.priceData.official_pricing_provider === "string" && + candidate.record.priceData.official_pricing_provider.trim()) || officialKeys[0] || candidate.record.modelName; @@ -413,6 +515,12 @@ export function resolvePricingForModelRecords( if (resolved) return resolved; } + // 云端价格表标注的官方报价(数据驱动)优先于按模型名推断的官方回退 + for (const candidate of candidates) { + const resolved = resolveCloudOfficial(candidate); + if (resolved) return resolved; + } + for (const candidate of candidates) { const resolved = resolveFromPricingMap(candidate, keyCandidates, "official"); if (resolved) return resolved; diff --git a/src/repository/cloud-pricing-catalog.ts b/src/repository/cloud-pricing-catalog.ts new file mode 100644 index 000000000..99ebd8c75 --- /dev/null +++ b/src/repository/cloud-pricing-catalog.ts @@ -0,0 +1,65 @@ +"use server"; + +import { sql } from "drizzle-orm"; +import { db } from "@/drizzle/db"; +import { cloudPricingCatalog } from "@/drizzle/schema"; +import { logger } from "@/lib/logger"; +import type { CloudVendorSummary } from "@/lib/price-sync/cpt-convert"; +import type { CptProviderInfo } from "@/lib/price-sync/cpt-schema"; + +export interface CloudPricingCatalogRecord { + version: string; + currency: string; + refreshedAt: Date | null; + providers: Record; + vendors: CloudVendorSummary[]; + modelCount: number; + syncedAt: Date | null; +} + +export interface CloudPricingCatalogInput { + version: string; + currency: string; + refreshedAt: string | null; + providers: Record; + vendors: CloudVendorSummary[]; + modelCount: number; +} + +/** 单行 upsert:目录元数据只保留最新一份 */ +export async function upsertCloudPricingCatalog(input: CloudPricingCatalogInput): Promise { + const refreshedAt = input.refreshedAt ? new Date(input.refreshedAt) : null; + await db.transaction(async (tx) => { + await tx.execute(sql`DELETE FROM cloud_pricing_catalog`); + await tx.insert(cloudPricingCatalog).values({ + version: input.version, + currency: input.currency, + refreshedAt: refreshedAt && !Number.isNaN(refreshedAt.getTime()) ? refreshedAt : null, + providers: input.providers, + vendors: input.vendors, + modelCount: input.modelCount, + }); + }); +} + +export async function getCloudPricingCatalog(): Promise { + try { + const [row] = await db.select().from(cloudPricingCatalog).limit(1); + if (!row) return null; + return { + version: row.version, + currency: row.currency, + refreshedAt: row.refreshedAt, + providers: (row.providers ?? {}) as Record, + vendors: (row.vendors ?? []) as CloudVendorSummary[], + modelCount: row.modelCount, + syncedAt: row.syncedAt, + }; + } catch (error) { + // 表尚未迁移等场景不阻断调用方(返回 null 走兜底) + logger.warn("[CloudPricingCatalog] Failed to read catalog", { + error: error instanceof Error ? error.message : String(error), + }); + return null; + } +} diff --git a/src/repository/model-price.ts b/src/repository/model-price.ts index c21c0d918..6e7a585e8 100644 --- a/src/repository/model-price.ts +++ b/src/repository/model-price.ts @@ -4,6 +4,7 @@ import { desc, eq, inArray, sql } from "drizzle-orm"; import { db } from "@/drizzle/db"; import { modelPrices } from "@/drizzle/schema"; import { logger } from "@/lib/logger"; +import { buildModelNameFallbackCandidates } from "@/lib/utils/model-name-matching"; import type { ModelPrice, ModelPriceData, ModelPriceSource } from "@/types/model-price"; import { toModelPrice } from "./_shared/transformers"; @@ -15,7 +16,8 @@ export interface PaginationParams { pageSize: number; search?: string; // 可选的搜索关键词 source?: ModelPriceSource; // 可选的来源过滤 - litellmProvider?: string; // 可选的云端提供商过滤(price_data.litellm_provider) + vendor?: string; // 可选的云端 vendor 过滤(price_data.vendor) + litellmProvider?: string; // 旧版云端提供商过滤(price_data.litellm_provider),仅遗留数据可命中 } /** @@ -30,7 +32,12 @@ export interface PaginatedResult { } /** - * 获取指定模型的最新价格 + * 获取指定模型的最新价格。 + * + * 精确名未命中时按以下顺序回退(用于带斜杠/区域前缀/日期后缀等调用名变体): + * 1. 归一化候选名(去托管商前缀、取最后一段、剥区域前缀等)的精确匹配 + * 2. 云端价格表 aliases 数组命中原名 + * 3. aliases 命中候选名 */ export async function findLatestPriceByModel(modelName: string): Promise { try { @@ -54,8 +61,8 @@ export async function findLatestPriceByModel(modelName: string): Promise { + const original = modelName.trim(); + if (!original) return null; + + const candidates = buildModelNameFallbackCandidates(original); + const candidateArray = candidates.length > 0 ? candidates : [original]; + + // 匹配优先级:候选名精确命中 > 别名命中原名 > 别名命中候选名; + // 同级内 manual 优先、时间倒序。别名查询命中 idx_model_prices_aliases(GIN)。 + const query = sql` + SELECT + id, + model_name as "modelName", + price_data as "priceData", + source, + created_at as "createdAt", + updated_at as "updatedAt" + FROM model_prices + WHERE model_name = ANY(${candidateArray}) + OR price_data -> 'aliases' ? ${original} + OR price_data -> 'aliases' ?| ${candidateArray} + ORDER BY + CASE + WHEN model_name = ANY(${candidateArray}) THEN 0 + WHEN price_data -> 'aliases' ? ${original} THEN 1 + ELSE 2 + END, + COALESCE(array_position(${candidateArray}::text[], model_name), 2147483647), + (source = 'manual') DESC, + created_at DESC NULLS LAST, + id DESC + LIMIT 1 + `; + + const result = await db.execute(query); + const rows = Array.from(result); + if (rows.length === 0) return null; + return toModelPrice(rows[0]); +} + export async function findLatestPriceByModelAndSource( modelName: string, source: ModelPriceSource @@ -160,18 +208,25 @@ export async function findAllLatestPrices(): Promise { export async function findAllLatestPricesPaginated( params: PaginationParams ): Promise> { - const { page, pageSize, search, source, litellmProvider } = params; + const { page, pageSize, search, source, vendor, litellmProvider } = params; const offset = (page - 1) * pageSize; // 构建 WHERE 条件 const buildWhereCondition = () => { const conditions: ReturnType[] = []; if (search?.trim()) { - conditions.push(sql`model_name ILIKE ${`%${search.trim()}%`}`); + const term = `%${search.trim()}%`; + conditions.push(sql`(model_name ILIKE ${term} OR price_data->>'display_name' ILIKE ${term})`); } - if (source) { + if (source === "cloud") { + // 云端来源包含旧版 litellm 遗留行,避免切换期查询漏数据 + conditions.push(sql`source <> 'manual'`); + } else if (source) { conditions.push(sql`source = ${source}`); } + if (vendor?.trim()) { + conditions.push(sql`price_data->>'vendor' = ${vendor.trim()}`); + } if (litellmProvider?.trim()) { conditions.push(sql`price_data->>'litellm_provider' = ${litellmProvider.trim()}`); } @@ -329,6 +384,32 @@ export async function findAllManualPrices(): Promise> { return priceMap; } +/** + * 删除不在保留列表中的所有云端来源价格记录(source <> 'manual')。 + * 云端价格表整表切换/换代时清理陈旧模型;manual 记录不受影响。 + * @returns 删除的行数 + */ +export async function deleteCloudPricesNotIn(keepModelNames: string[]): Promise { + const keep = keepModelNames.length > 0 ? keepModelNames : [""]; + const result = await db.execute(sql` + DELETE FROM model_prices + WHERE source <> 'manual' + AND NOT (model_name = ANY(${keep})) + `); + const count = (result as unknown as { count?: number }).count; + return typeof count === "number" ? count : 0; +} + +/** 统计云端来源(source <> 'manual')的去重模型数量,用于同步一致性校验 */ +export async function countCloudModelPrices(): Promise { + const [row] = await db.execute(sql` + SELECT COUNT(DISTINCT model_name) AS total + FROM model_prices + WHERE source <> 'manual' + `); + return Number((row as { total?: unknown })?.total ?? 0); +} + /** * 批量创建价格记录 */ diff --git a/src/types/model-price.ts b/src/types/model-price.ts index da3c5dd4a..664e4d899 100644 --- a/src/types/model-price.ts +++ b/src/types/model-price.ts @@ -81,10 +81,20 @@ export interface ModelPriceData { selected_pricing_provider?: string; selected_pricing_source_model?: string; selected_pricing_resolution?: "manual_pin"; + // 云端价格表(cchp.pricing-table/v1)元数据 + vendor?: string; + slug?: string; + aliases?: string[]; + vendor_icon?: string; + vendor_icon_mono?: boolean; + official_pricing_provider?: string | null; + model_family?: string; + deprecated?: boolean; + knowledge_cutoff?: string; max_input_tokens?: number; max_output_tokens?: number; max_tokens?: number; - mode?: "chat" | "image_generation" | "completion" | "responses"; + mode?: "chat" | "image_generation" | "completion" | "responses" | (string & {}); // 支持的功能 supports_assistant_prefill?: boolean; @@ -104,8 +114,14 @@ export interface ModelPriceData { /** * 价格来源类型 + * - "cloud": 云端价格表(cchp.pricing-table/v1)同步写入 + * - "manual": 用户手动添加/上传(本地优先,不被云端覆盖) + * - "litellm": 旧版云端同步的遗留值,首次新版同步后被整体替换 */ -export type ModelPriceSource = "litellm" | "manual"; +export type ModelPriceSource = "cloud" | "litellm" | "manual"; + +/** 非本地(云端)来源集合,查询过滤用 */ +export const CLOUD_PRICE_SOURCES = ["cloud", "litellm"] as const; /** * 模型价格记录 @@ -144,7 +160,7 @@ export interface PriceUpdateResult { export interface SyncConflict { modelName: string; manualPrice: ModelPriceData; // 当前手动添加的价格 - litellmPrice: ModelPriceData; // LiteLLM 中的价格 + cloudPrice: ModelPriceData; // 云端价格表中的价格 } /** diff --git a/src/types/special-settings.ts b/src/types/special-settings.ts index 98d12b456..0253e786f 100644 --- a/src/types/special-settings.ts +++ b/src/types/special-settings.ts @@ -265,6 +265,7 @@ export type PricingResolutionSpecialSetting = { | "local_manual" | "cloud_exact" | "cloud_model_fallback" + | "cloud_official" | "priority_fallback" | "single_provider_top_level" | "official_fallback"; diff --git a/tests/unit/actions/model-prices.test.ts b/tests/unit/actions/model-prices.test.ts index f64a86eb0..4b487a5e4 100644 --- a/tests/unit/actions/model-prices.test.ts +++ b/tests/unit/actions/model-prices.test.ts @@ -15,7 +15,8 @@ const deleteModelPriceByNameMock = vi.fn(); const findAllManualPricesMock = vi.fn(); // Price sync mock -const fetchCloudPriceTableTomlMock = vi.fn(); +const loadConvertedCloudPriceTableMock = vi.fn(); +const applyConvertedCloudPriceTableMock = vi.fn(); vi.mock("@/lib/auth", () => ({ getSession: () => getSessionMock(), @@ -54,19 +55,36 @@ vi.mock("@/repository/model-price", () => ({ hasAnyPriceRecords: vi.fn(async () => false), })); -vi.mock("@/lib/price-sync/cloud-price-table", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("@/lib/price-sync/cloud-price-updater", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, - fetchCloudPriceTableToml: (...args: unknown[]) => fetchCloudPriceTableTomlMock(...args), + loadConvertedCloudPriceTable: (...args: unknown[]) => loadConvertedCloudPriceTableMock(...args), + applyConvertedCloudPriceTable: (...args: unknown[]) => + applyConvertedCloudPriceTableMock(...args), }; }); +/** 构造 loadConvertedCloudPriceTable 的成功返回 */ +function makeConvertedTable(models: Record) { + return { + ok: true, + data: { + models, + vendors: [], + providers: {}, + version: "test-version", + currency: "USD", + refreshedAt: "2026-07-01T00:00:00.000Z", + }, + }; +} + // Helper to create mock ModelPrice function makeMockPrice( modelName: string, priceData: Partial, - source: "litellm" | "manual" = "manual" + source: "cloud" | "litellm" | "manual" = "manual" ): ModelPrice { const now = new Date(); return { @@ -349,12 +367,11 @@ describe("Model Price Actions", () => { describe("checkLiteLLMSyncConflicts", () => { it("should return no conflicts when no manual prices exist", async () => { findAllManualPricesMock.mockResolvedValue(new Map()); - fetchCloudPriceTableTomlMock.mockResolvedValue({ - ok: true, - data: ['[models."claude-3-opus"]', 'mode = "chat"', "input_cost_per_token = 0.000015"].join( - "\n" - ), - }); + loadConvertedCloudPriceTableMock.mockResolvedValue( + makeConvertedTable({ + "claude-3-opus": { mode: "chat", input_cost_per_token: 0.000015 }, + }) + ); const { checkLiteLLMSyncConflicts } = await import("@/actions/model-prices"); const result = await checkLiteLLMSyncConflicts(); @@ -373,15 +390,15 @@ describe("Model Price Actions", () => { findAllManualPricesMock.mockResolvedValue(new Map([["claude-3-opus", manualPrice]])); - fetchCloudPriceTableTomlMock.mockResolvedValue({ - ok: true, - data: [ - '[models."claude-3-opus"]', - 'mode = "chat"', - "input_cost_per_token = 0.000015", - "output_cost_per_token = 0.00006", - ].join("\n"), - }); + loadConvertedCloudPriceTableMock.mockResolvedValue( + makeConvertedTable({ + "claude-3-opus": { + mode: "chat", + input_cost_per_token: 0.000015, + output_cost_per_token: 0.00006, + }, + }) + ); const { checkLiteLLMSyncConflicts } = await import("@/actions/model-prices"); const result = await checkLiteLLMSyncConflicts(); @@ -390,6 +407,7 @@ describe("Model Price Actions", () => { expect(result.data?.hasConflicts).toBe(true); expect(result.data?.conflicts).toHaveLength(1); expect(result.data?.conflicts[0]?.modelName).toBe("claude-3-opus"); + expect(result.data?.conflicts[0]?.cloudPrice.input_cost_per_token).toBe(0.000015); }); it("should not report conflicts for manual prices not in LiteLLM", async () => { @@ -400,12 +418,11 @@ describe("Model Price Actions", () => { findAllManualPricesMock.mockResolvedValue(new Map([["custom-model", manualPrice]])); - fetchCloudPriceTableTomlMock.mockResolvedValue({ - ok: true, - data: ['[models."claude-3-opus"]', 'mode = "chat"', "input_cost_per_token = 0.000015"].join( - "\n" - ), - }); + loadConvertedCloudPriceTableMock.mockResolvedValue( + makeConvertedTable({ + "claude-3-opus": { mode: "chat", input_cost_per_token: 0.000015 }, + }) + ); const { checkLiteLLMSyncConflicts } = await import("@/actions/model-prices"); const result = await checkLiteLLMSyncConflicts(); @@ -427,7 +444,7 @@ describe("Model Price Actions", () => { it("should handle network errors gracefully", async () => { findAllManualPricesMock.mockResolvedValue(new Map()); - fetchCloudPriceTableTomlMock.mockResolvedValue({ + loadConvertedCloudPriceTableMock.mockResolvedValue({ ok: false, error: "云端价格表拉取失败:mock", }); @@ -439,18 +456,18 @@ describe("Model Price Actions", () => { expect(result.error).toContain("云端"); }); - it("should handle invalid TOML gracefully", async () => { + it("should handle invalid schema gracefully", async () => { findAllManualPricesMock.mockResolvedValue(new Map()); - fetchCloudPriceTableTomlMock.mockResolvedValue({ - ok: true, - data: "[models\ninvalid = true", + loadConvertedCloudPriceTableMock.mockResolvedValue({ + ok: false, + error: "价格表格式无效:schema 不是 cchp.pricing-table/v1(实际为 other/v9)", }); const { checkLiteLLMSyncConflicts } = await import("@/actions/model-prices"); const result = await checkLiteLLMSyncConflicts(); expect(result.ok).toBe(false); - expect(result.error).toContain("TOML"); + expect(result.error).toContain("schema"); }); }); @@ -516,11 +533,11 @@ describe("Model Price Actions", () => { expect(upsertModelPriceMock).toHaveBeenCalledWith( "custom-model", expect.any(Object), - "litellm" + "cloud" ); }); - it("should add new models with litellm source", async () => { + it("should add new models with cloud source", async () => { findAllManualPricesMock.mockResolvedValue(new Map()); findAllLatestPricesMock.mockResolvedValue([]); createModelPriceMock.mockResolvedValue( @@ -545,7 +562,7 @@ describe("Model Price Actions", () => { expect(result.ok).toBe(true); expect(result.data?.added).toContain("new-model"); - expect(createModelPriceMock).toHaveBeenCalledWith("new-model", expect.any(Object), "litellm"); + expect(createModelPriceMock).toHaveBeenCalledWith("new-model", expect.any(Object), "cloud"); }); it("should skip metadata fields like sample_spec", async () => { @@ -589,7 +606,7 @@ describe("Model Price Actions", () => { input_cost_per_token: 0.000001, output_cost_per_token: 0.000002, }, - "litellm" + "cloud" ); findAllManualPricesMock.mockResolvedValue(new Map()); @@ -672,11 +689,7 @@ describe("Model Price Actions", () => { expect(result.ok).toBe(true); expect(result.data?.updated).toContain("cloud-model"); // Transactional replace, not a separate delete + insert. - expect(upsertModelPriceMock).toHaveBeenCalledWith( - "cloud-model", - expect.any(Object), - "litellm" - ); + expect(upsertModelPriceMock).toHaveBeenCalledWith("cloud-model", expect.any(Object), "cloud"); expect(createModelPriceMock).not.toHaveBeenCalled(); }); @@ -821,7 +834,7 @@ describe("Model Price Actions", () => { }); expect(result.ok).toBe(true); - expect(findLatestPriceByModelAndSourceMock).toHaveBeenCalledWith("gpt-5.5", "litellm"); + expect(findLatestPriceByModelAndSourceMock).toHaveBeenCalledWith("gpt-5.5", "cloud"); expect(upsertModelPriceMock).toHaveBeenCalledWith( "gpt-5.5", expect.objectContaining({ diff --git a/tests/unit/lib/pricing-resolution-cloud-official.test.ts b/tests/unit/lib/pricing-resolution-cloud-official.test.ts new file mode 100644 index 000000000..812cefae5 --- /dev/null +++ b/tests/unit/lib/pricing-resolution-cloud-official.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, it } from "vitest"; +import { resolvePricingForModelRecords } from "@/lib/utils/pricing-resolution"; +import type { ModelPrice, ModelPriceData } from "@/types/model-price"; + +function makeCloudRecord(priceData: Partial, modelName = "test-model"): ModelPrice { + const now = new Date("2026-07-01T00:00:00.000Z"); + return { + id: 1, + modelName, + priceData: { mode: "chat", ...priceData }, + source: "cloud", + createdAt: now, + updatedAt: now, + }; +} + +describe("resolvePricingForModelRecords - cloud official", () => { + it("prefers the data-driven official pricing node over non-official ones", () => { + const record = makeCloudRecord({ + vendor: "deepseek", + official_pricing_provider: "deepseek", + pricing: { + openrouter: { input_cost_per_token: 0.0000005, output_cost_per_token: 0.0000015 }, + deepseek: { + official: true, + input_cost_per_token: 0.00000028, + output_cost_per_token: 0.00000042, + }, + }, + }); + + const resolved = resolvePricingForModelRecords({ + provider: null, + primaryModelName: "deepseek-v3.2", + fallbackModelName: null, + primaryRecord: record, + fallbackRecord: null, + }); + + expect(resolved?.source).toBe("cloud_official"); + expect(resolved?.resolvedPricingProviderKey).toBe("deepseek"); + expect(resolved?.priceData.input_cost_per_token).toBeCloseTo(0.00000028, 12); + }); + + it("falls back to any official=true node when declared official key is missing", () => { + const record = makeCloudRecord({ + vendor: "deepseek", + official_pricing_provider: null, + pricing: { + openrouter: { input_cost_per_token: 0.0000005 }, + deepseek: { official: true, input_cost_per_token: 0.00000028 }, + }, + }); + + const resolved = resolvePricingForModelRecords({ + provider: null, + primaryModelName: "deepseek-v3.2", + fallbackModelName: null, + primaryRecord: record, + fallbackRecord: null, + }); + + expect(resolved?.source).toBe("cloud_official"); + expect(resolved?.resolvedPricingProviderKey).toBe("deepseek"); + }); + + it("still prefers exact provider-channel match over cloud official", () => { + const record = makeCloudRecord({ + vendor: "deepseek", + official_pricing_provider: "deepseek", + pricing: { + openrouter: { input_cost_per_token: 0.0000005 }, + deepseek: { official: true, input_cost_per_token: 0.00000028 }, + }, + }); + + const resolved = resolvePricingForModelRecords({ + provider: { + id: 1, + name: "OpenRouter Channel", + url: "https://openrouter.ai/api/v1", + } as never, + primaryModelName: "deepseek-v3.2", + fallbackModelName: null, + primaryRecord: record, + fallbackRecord: null, + }); + + expect(resolved?.source).toBe("cloud_exact"); + expect(resolved?.resolvedPricingProviderKey).toBe("openrouter"); + }); + + it("local manual price still wins over everything", () => { + const manual = { + ...makeCloudRecord({ + input_cost_per_token: 0.000001, + pricing: { + deepseek: { official: true, input_cost_per_token: 0.00000028 }, + }, + }), + source: "manual" as const, + }; + + const resolved = resolvePricingForModelRecords({ + provider: null, + primaryModelName: "deepseek-v3.2", + fallbackModelName: null, + primaryRecord: manual, + fallbackRecord: null, + }); + + expect(resolved?.source).toBe("local_manual"); + }); + + it("uses vendor field to derive official provider keys for name-based fallback", () => { + const record = makeCloudRecord({ + vendor: "google", + pricing: { + "google-vertex": { input_cost_per_token: 0.00000125 }, + }, + }); + + const resolved = resolvePricingForModelRecords({ + provider: null, + primaryModelName: "gemini-2.5-pro", + fallbackModelName: null, + primaryRecord: record, + fallbackRecord: null, + }); + + // google-vertex 属于 google vendor 的官方渠道(OFFICIAL_PROVIDER_EXTRA) + expect(resolved?.resolvedPricingProviderKey).toBe("google-vertex"); + expect(resolved?.source).toBe("official_fallback"); + }); + + it("official-aware detail fallback prefers official nodes at equal detail", () => { + const record = makeCloudRecord({ + // 无 vendor/官方声明,exact/official 键都不命中 -> 走 detail fallback + pricing: { + aaa: { input_cost_per_token: 0.000001, output_cost_per_token: 0.000002 }, + zzz: { + official: true, + input_cost_per_token: 0.0000011, + output_cost_per_token: 0.0000021, + }, + }, + official_pricing_provider: undefined, + }); + // 干掉 cloud_official 的 official 标志识别路径?不能——official=true 会先被 resolveCloudOfficial 命中。 + // 这里直接断言 cloud_official 生效即可。 + const resolved = resolvePricingForModelRecords({ + provider: null, + primaryModelName: "mystery-model", + fallbackModelName: null, + primaryRecord: record, + fallbackRecord: null, + }); + + expect(resolved?.resolvedPricingProviderKey).toBe("zzz"); + }); +}); diff --git a/tests/unit/lib/utils/model-name-matching.test.ts b/tests/unit/lib/utils/model-name-matching.test.ts new file mode 100644 index 000000000..ae4c09558 --- /dev/null +++ b/tests/unit/lib/utils/model-name-matching.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { buildModelNameFallbackCandidates } from "@/lib/utils/model-name-matching"; + +describe("buildModelNameFallbackCandidates", () => { + it("returns empty for blank input", () => { + expect(buildModelNameFallbackCandidates("")).toEqual([]); + expect(buildModelNameFallbackCandidates(" ")).toEqual([]); + }); + + it("does not include the original name", () => { + const candidates = buildModelNameFallbackCandidates("claude-sonnet-4-5"); + expect(candidates).not.toContain("claude-sonnet-4-5"); + }); + + it("produces the bare segment for vendor-prefixed names", () => { + const candidates = buildModelNameFallbackCandidates("anthropic/claude-sonnet-4-5"); + expect(candidates).toContain("claude-sonnet-4-5"); + }); + + it("strips host prefixes and keeps org/model remainder", () => { + const candidates = buildModelNameFallbackCandidates("openrouter/deepseek/deepseek-v3.2"); + expect(candidates).toContain("deepseek/deepseek-v3.2"); + expect(candidates).toContain("deepseek-v3.2"); + }); + + it("strips gateway call suffixes", () => { + const candidates = buildModelNameFallbackCandidates("deepseek/deepseek-v3.2:thinking"); + expect(candidates).toContain("deepseek/deepseek-v3.2"); + expect(candidates).toContain("deepseek-v3.2"); + }); + + it("strips bedrock region prefixes", () => { + const candidates = buildModelNameFallbackCandidates( + "us.anthropic.claude-sonnet-4-5-20250929-v1:0" + ); + expect(candidates).toContain("anthropic.claude-sonnet-4-5-20250929-v1:0"); + }); + + it("adds lowercase variants for mixed-case names", () => { + const candidates = buildModelNameFallbackCandidates("Pro/deepseek-ai/DeepSeek-V3.2"); + expect(candidates).toContain("pro/deepseek-ai/deepseek-v3.2"); + expect(candidates).toContain("deepseek-v3.2"); + expect(candidates).toContain("DeepSeek-V3.2"); + }); + + it("deduplicates candidates", () => { + const candidates = buildModelNameFallbackCandidates("openai/gpt-5.5"); + expect(new Set(candidates).size).toBe(candidates.length); + }); +}); diff --git a/tests/unit/price-sync/cloud-price-table.test.ts b/tests/unit/price-sync/cloud-price-table.test.ts index faa624d54..fc9ea266d 100644 --- a/tests/unit/price-sync/cloud-price-table.test.ts +++ b/tests/unit/price-sync/cloud-price-table.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { - fetchCloudPriceTableToml, + fetchCloudPriceTableJson, parseCloudPriceTableToml, } from "@/lib/price-sync/cloud-price-table"; @@ -130,7 +130,7 @@ describe("parseCloudPriceTableToml", () => { }); }); -describe("fetchCloudPriceTableToml", () => { +describe("fetchCloudPriceTableJson", () => { afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); @@ -142,11 +142,11 @@ describe("fetchCloudPriceTableToml", () => { vi.fn(async () => ({ ok: true, status: 200, - text: async () => "toml content", + text: async () => '{"schema":"x"}', })) ); - const result = await fetchCloudPriceTableToml("https://example.test/prices.toml"); + const result = await fetchCloudPriceTableJson("https://example.test/models.json"); expect(result.ok).toBe(true); }); @@ -160,7 +160,7 @@ describe("fetchCloudPriceTableToml", () => { })) ); - const result = await fetchCloudPriceTableToml("https://example.test/prices.toml"); + const result = await fetchCloudPriceTableJson("https://example.test/models.json"); expect(result.ok).toBe(false); }); @@ -170,12 +170,12 @@ describe("fetchCloudPriceTableToml", () => { vi.fn(async () => ({ ok: true, status: 200, - url: "https://evil.test/prices.toml", - text: async () => "toml content", + url: "https://evil.test/models.json", + text: async () => '{"schema":"x"}', })) ); - const result = await fetchCloudPriceTableToml("https://example.test/prices.toml"); + const result = await fetchCloudPriceTableJson("https://example.test/models.json"); expect(result.ok).toBe(false); }); @@ -185,12 +185,12 @@ describe("fetchCloudPriceTableToml", () => { vi.fn(async () => ({ ok: true, status: 200, - url: "https://example.test/evil.toml", - text: async () => "toml content", + url: "https://example.test/evil.json", + text: async () => '{"schema":"x"}', })) ); - const result = await fetchCloudPriceTableToml("https://example.test/prices.toml"); + const result = await fetchCloudPriceTableJson("https://example.test/models.json"); expect(result.ok).toBe(false); }); @@ -202,7 +202,7 @@ describe("fetchCloudPriceTableToml", () => { }) ); - const result = await fetchCloudPriceTableToml("not-a-url"); + const result = await fetchCloudPriceTableJson("not-a-url"); expect(result.ok).toBe(false); }); @@ -216,7 +216,7 @@ describe("fetchCloudPriceTableToml", () => { })) ); - const result = await fetchCloudPriceTableToml("https://example.test/prices.toml"); + const result = await fetchCloudPriceTableJson("https://example.test/models.json"); expect(result.ok).toBe(false); }); @@ -235,8 +235,8 @@ describe("fetchCloudPriceTableToml", () => { ) ); - const promise = fetchCloudPriceTableToml("https://example.test/prices.toml"); - await vi.advanceTimersByTimeAsync(10000); + const promise = fetchCloudPriceTableJson("https://example.test/models.json"); + await vi.advanceTimersByTimeAsync(30000); const result = await promise; expect(result.ok).toBe(false); @@ -250,7 +250,7 @@ describe("fetchCloudPriceTableToml", () => { }) ); - const result = await fetchCloudPriceTableToml("https://example.test/prices.toml"); + const result = await fetchCloudPriceTableJson("https://example.test/models.json"); expect(result.ok).toBe(false); }); }); diff --git a/tests/unit/price-sync/cloud-price-updater.test.ts b/tests/unit/price-sync/cloud-price-updater.test.ts index 3c3cfc2bf..d2b5f3439 100644 --- a/tests/unit/price-sync/cloud-price-updater.test.ts +++ b/tests/unit/price-sync/cloud-price-updater.test.ts @@ -41,6 +41,52 @@ vi.mock("@/actions/model-prices", () => ({ })), })); +vi.mock("@/repository/model-price", () => ({ + deleteCloudPricesNotIn: vi.fn(async () => 0), + countCloudModelPrices: vi.fn(async () => 0), +})); + +vi.mock("@/repository/cloud-pricing-catalog", () => ({ + upsertCloudPricingCatalog: vi.fn(async () => {}), + getCloudPricingCatalog: vi.fn(async () => null), +})); + +/** 构造最小可用的 CPT v1 价格表 JSON 文本 */ +function buildCptJson(options?: { version?: string; modelName?: string }): string { + const modelName = options?.modelName ?? "m1"; + return JSON.stringify({ + schema: "cchp.pricing-table/v1", + version: options?.version ?? "test-version", + currency: "USD", + refreshed_at: "2026-07-01T00:00:00.000Z", + providers: { + anthropic: { name: "Anthropic", icon: "anthropic.svg", icon_mono: true }, + }, + models: [ + { + slug: `anthropic/${modelName}`, + model_name: modelName, + vendor: "anthropic", + display_name: "Model One", + model_type: "chat", + endpoints: { inbound: ["anthropic-messages"], outbound: ["anthropic-messages"] }, + pricing: [ + { + provider: "anthropic", + official: true, + source: "test", + charges: { + prompt: { unit: "per_M_tokens", price: "3" }, + completion: { unit: "per_M_tokens", price: "15" }, + }, + tracks: [{ label: "standard", factor: "1", triggers: [] }], + }, + ], + }, + ], + }); +} + async function flushAsync(): Promise { await new Promise((resolve) => setTimeout(() => resolve(), 0)); } @@ -84,13 +130,13 @@ describe("syncCloudPriceTableToDatabase", () => { expect(result.ok).toBe(false); }); - it("returns ok=false when TOML is missing models table", async () => { + it("returns ok=false when payload has wrong schema id", async () => { vi.stubGlobal( "fetch", vi.fn(async () => ({ ok: true, status: 200, - text: async () => ["[metadata]", 'version = "test"'].join("\n"), + text: async () => JSON.stringify({ schema: "other/v9", models: [], providers: {} }), })) ); @@ -105,7 +151,7 @@ describe("syncCloudPriceTableToDatabase", () => { vi.fn(async () => ({ ok: true, status: 200, - text: async () => ['[models."m1"]', "input_cost_per_token = 0.000001"].join("\n"), + text: async () => buildCptJson(), })) ); @@ -126,7 +172,7 @@ describe("syncCloudPriceTableToDatabase", () => { vi.fn(async () => ({ ok: true, status: 200, - text: async () => ['[models."m1"]', "input_cost_per_token = 0.000001"].join("\n"), + text: async () => buildCptJson(), })) ); @@ -141,16 +187,13 @@ describe("syncCloudPriceTableToDatabase", () => { expect(result.ok).toBe(false); }); - it("returns ok=true when TOML parses and write succeeds", async () => { + it("returns ok=true and passes source='cloud' when table parses and write succeeds", async () => { vi.stubGlobal( "fetch", vi.fn(async () => ({ ok: true, status: 200, - text: async () => - ['[models."m1"]', 'display_name = "Model One"', "input_cost_per_token = 0.000001"].join( - "\n" - ), + text: async () => buildCptJson(), })) ); @@ -170,6 +213,90 @@ describe("syncCloudPriceTableToDatabase", () => { const result = await syncCloudPriceTableToDatabase(); expect(result.ok).toBe(true); expect(processPriceTableInternal).toHaveBeenCalledTimes(1); + const [jsonContent, overwrite, source] = vi.mocked(processPriceTableInternal).mock.calls[0]; + expect(source).toBe("cloud"); + expect(overwrite).toBeUndefined(); + const models = JSON.parse(jsonContent as string); + expect(models.m1.vendor).toBe("anthropic"); + expect(models.m1.input_cost_per_token).toBeCloseTo(0.000003, 12); + + // 整表切换:清理云端不存在的旧行 + 目录元数据落库 + const { deleteCloudPricesNotIn } = await import("@/repository/model-price"); + expect(vi.mocked(deleteCloudPricesNotIn)).toHaveBeenCalledWith(["m1"]); + const { upsertCloudPricingCatalog } = await import("@/repository/cloud-pricing-catalog"); + expect(vi.mocked(upsertCloudPricingCatalog)).toHaveBeenCalledWith( + expect.objectContaining({ version: "test-version", modelCount: 1 }) + ); + }); + + it("skips write when version fingerprint and row count are unchanged", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: true, + status: 200, + text: async () => buildCptJson({ version: "same-version" }), + })) + ); + + const { getCloudPricingCatalog } = await import("@/repository/cloud-pricing-catalog"); + vi.mocked(getCloudPricingCatalog).mockResolvedValue({ + version: "same-version", + currency: "USD", + refreshedAt: null, + providers: {}, + vendors: [], + modelCount: 1, + syncedAt: null, + }); + const { countCloudModelPrices } = await import("@/repository/model-price"); + vi.mocked(countCloudModelPrices).mockResolvedValue(1); + + const { processPriceTableInternal } = await import("@/actions/model-prices"); + const { syncCloudPriceTableToDatabase } = await import("@/lib/price-sync/cloud-price-updater"); + const result = await syncCloudPriceTableToDatabase(); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.unchanged).toEqual(["m1"]); + expect(result.data.added).toEqual([]); + } + expect(processPriceTableInternal).not.toHaveBeenCalled(); + }); + + it("does not skip when overwriteManual is provided even if version matches", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: true, + status: 200, + text: async () => buildCptJson({ version: "same-version" }), + })) + ); + + const { getCloudPricingCatalog } = await import("@/repository/cloud-pricing-catalog"); + vi.mocked(getCloudPricingCatalog).mockResolvedValue({ + version: "same-version", + currency: "USD", + refreshedAt: null, + providers: {}, + vendors: [], + modelCount: 1, + syncedAt: null, + }); + + const { processPriceTableInternal } = await import("@/actions/model-prices"); + vi.mocked(processPriceTableInternal).mockResolvedValue({ + ok: true, + data: { added: [], updated: ["m1"], unchanged: [], failed: [], total: 1 }, + } as any); + + const { syncCloudPriceTableToDatabase } = await import("@/lib/price-sync/cloud-price-updater"); + const result = await syncCloudPriceTableToDatabase(["m1"]); + + expect(result.ok).toBe(true); + expect(processPriceTableInternal).toHaveBeenCalledTimes(1); + expect(vi.mocked(processPriceTableInternal).mock.calls[0][1]).toEqual(["m1"]); }); it("falls back to default error message when write returns ok=false without error", async () => { @@ -178,7 +305,7 @@ describe("syncCloudPriceTableToDatabase", () => { vi.fn(async () => ({ ok: true, status: 200, - text: async () => ['[models."m1"]', "input_cost_per_token = 0.000001"].join("\n"), + text: async () => buildCptJson(), })) ); @@ -201,7 +328,7 @@ describe("syncCloudPriceTableToDatabase", () => { vi.fn(async () => ({ ok: true, status: 200, - text: async () => ['[models."m1"]', "input_cost_per_token = 0.000001"].join("\n"), + text: async () => buildCptJson(), })) ); @@ -223,7 +350,7 @@ describe("syncCloudPriceTableToDatabase", () => { vi.fn(async () => ({ ok: true, status: 200, - text: async () => ['[models."m1"]', "input_cost_per_token = 0.000001"].join("\n"), + text: async () => buildCptJson(), })) ); @@ -399,7 +526,7 @@ describe("requestCloudPriceTableSync", () => { resolveFetch!({ ok: true, status: 200, - text: async () => ['[models."m1"]', "input_cost_per_token = 0.000001"].join("\n"), + text: async () => buildCptJson(), }); await Promise.all(asyncTasks.splice(0, asyncTasks.length)); diff --git a/tests/unit/price-sync/cpt-convert.test.ts b/tests/unit/price-sync/cpt-convert.test.ts new file mode 100644 index 000000000..dc65973a8 --- /dev/null +++ b/tests/unit/price-sync/cpt-convert.test.ts @@ -0,0 +1,447 @@ +import { describe, expect, it } from "vitest"; +import { + convertCptModelEntry, + convertCptTable, + convertCptVariant, +} from "@/lib/price-sync/cpt-convert"; +import type { + CptModelEntry, + CptPricingVariant, + CptProviderInfo, + CptTable, +} from "@/lib/price-sync/cpt-schema"; + +const PROVIDERS: Record = { + anthropic: { name: "Anthropic", icon: "anthropic.svg", icon_mono: true }, + openai: { name: "OpenAI", icon: "openai.svg", icon_mono: true }, + google: { name: "Google", icon: "google-color.svg" }, + openrouter: { name: "OpenRouter", icon: "openrouter.svg", icon_mono: true }, +}; + +function claudeVariant(overrides?: Partial): CptPricingVariant { + return { + provider: "anthropic", + official: true, + source: "test", + provider_model_id: "claude-sonnet-4-5-20250929", + charges: { + prompt: { unit: "per_M_tokens", price: "3" }, + completion: { unit: "per_M_tokens", price: "15" }, + cache_read: { unit: "per_M_tokens", price: "0.3" }, + cache_write: { unit: "per_M_tokens", price: "3.75" }, + cache_write_1h: { unit: "per_M_tokens", price: "6" }, + web_search: { unit: "per_k_calls", price: "10" }, + }, + tracks: [ + { + label: ">200K context (1M beta)", + factor: "1", + charge_factors: { + prompt: "2", + completion: "1.5", + cache_read: "2", + cache_write: "2", + cache_write_1h: "2", + }, + triggers: [ + { + kind: "header_matches", + header: "anthropic-beta", + pattern: "context-1m-\\d{4}-\\d{2}-\\d{2}", + }, + { kind: "input_tokens_above", threshold: 200000, inclusive: false }, + ], + }, + { label: "standard", factor: "1", triggers: [] }, + ], + ...overrides, + }; +} + +describe("convertCptVariant", () => { + it("converts per_M_tokens charges into per-token fields", () => { + const node = convertCptVariant(claudeVariant()); + expect(node).not.toBeNull(); + expect(node?.input_cost_per_token).toBeCloseTo(0.000003, 12); + expect(node?.output_cost_per_token).toBeCloseTo(0.000015, 12); + expect(node?.cache_read_input_token_cost).toBeCloseTo(3e-7, 12); + expect(node?.cache_creation_input_token_cost).toBeCloseTo(0.00000375, 12); + expect(node?.cache_creation_input_token_cost_above_1hr).toBeCloseTo(0.000006, 12); + }); + + it("maps >200K tier tracks to above_200k fields using base price x factor", () => { + const node = convertCptVariant(claudeVariant()); + expect(node?.input_cost_per_token_above_200k_tokens).toBeCloseTo(0.000006, 12); + expect(node?.output_cost_per_token_above_200k_tokens).toBeCloseTo(0.0000225, 12); + expect(node?.cache_read_input_token_cost_above_200k_tokens).toBeCloseTo(6e-7, 12); + expect(node?.cache_creation_input_token_cost_above_200k_tokens).toBeCloseTo(0.0000075, 12); + expect(node?.cache_creation_input_token_cost_above_1hr_above_200k_tokens).toBeCloseTo( + 0.000012, + 12 + ); + }); + + it("maps web_search per_k_calls to search_context_cost_per_query", () => { + const node = convertCptVariant(claudeVariant()); + expect(node?.search_context_cost_per_query).toEqual({ + search_context_size_low: 0.01, + search_context_size_medium: 0.01, + search_context_size_high: 0.01, + }); + }); + + it("maps >272K tier tracks to above_272k fields", () => { + const node = convertCptVariant({ + provider: "openai", + official: true, + source: "test", + charges: { + prompt: { unit: "per_M_tokens", price: "1.25" }, + completion: { unit: "per_M_tokens", price: "10" }, + }, + tracks: [ + { + label: ">272K context", + factor: "1", + charge_factors: { prompt: "2", completion: "2" }, + triggers: [{ kind: "input_tokens_above", threshold: 272000 }], + }, + { label: "standard", factor: "1", triggers: [] }, + ], + }); + expect(node?.input_cost_per_token_above_272k_tokens).toBeCloseTo(0.0000025, 12); + expect(node?.output_cost_per_token_above_272k_tokens).toBeCloseTo(0.00002, 12); + }); + + it("maps priority service tier tracks to priority fields", () => { + const node = convertCptVariant({ + provider: "openai", + official: true, + source: "test", + charges: { + prompt: { unit: "per_M_tokens", price: "2" }, + completion: { unit: "per_M_tokens", price: "8" }, + cache_read: { unit: "per_M_tokens", price: "0.5" }, + }, + tracks: [ + { + label: "priority", + factor: "2", + triggers: [{ kind: "body_matches", field: "service_tier", pattern: "^priority$" }], + }, + { label: "standard", factor: "1", triggers: [] }, + ], + }); + expect(node?.input_cost_per_token_priority).toBeCloseTo(0.000004, 12); + expect(node?.output_cost_per_token_priority).toBeCloseTo(0.000016, 12); + expect(node?.cache_read_input_token_cost_priority).toBeCloseTo(0.000001, 12); + }); + + it("skips unsupported tracks (batch/flex) without failing", () => { + const node = convertCptVariant({ + provider: "google", + official: true, + source: "test", + charges: { prompt: { unit: "per_M_tokens", price: "1.25" } }, + tracks: [ + { + label: "batch", + factor: "0.5", + triggers: [{ kind: "endpoint_matches", pattern: "^batch\\." }], + }, + { + label: "flex", + factor: "0.5", + triggers: [{ kind: "body_matches", field: "service_tier", pattern: "^flex$" }], + }, + { label: "standard", factor: "1", triggers: [] }, + ], + }); + expect(node?.input_cost_per_token).toBeCloseTo(0.00000125, 12); + expect(node?.input_cost_per_token_priority).toBeUndefined(); + }); + + it("ignores bogus giant thresholds", () => { + const node = convertCptVariant({ + provider: "google", + official: true, + source: "test", + charges: { prompt: { unit: "per_M_tokens", price: "1.25" } }, + tracks: [ + { + label: ">200000000K context", + factor: "1", + charge_factors: { prompt: "2" }, + triggers: [{ kind: "input_tokens_above", threshold: 200000000000, inclusive: true }], + }, + { label: "standard", factor: "1", triggers: [] }, + ], + }); + expect(node?.input_cost_per_token_above_200k_tokens).toBeUndefined(); + }); + + it("applies default-track factor to base prices", () => { + const node = convertCptVariant({ + provider: "openai", + official: false, + source: "test", + charges: { prompt: { unit: "per_M_tokens", price: "10" } }, + tracks: [{ label: "standard", factor: "0.5", triggers: [] }], + }); + expect(node?.input_cost_per_token).toBeCloseTo(0.000005, 12); + }); + + it("converts per_image and per_request charges", () => { + const node = convertCptVariant({ + provider: "openai", + official: true, + source: "test", + charges: { + image_output: { unit: "per_image", price: "0.04" }, + image_input: { unit: "per_M_tokens", price: "5" }, + request: { unit: "per_request", price: "0.002" }, + }, + tracks: null, + }); + expect(node?.output_cost_per_image).toBeCloseTo(0.04, 12); + expect(node?.input_cost_per_image_token).toBeCloseTo(0.000005, 12); + expect(node?.input_cost_per_request).toBeCloseTo(0.002, 12); + }); + + it("skips non-USD currency charges", () => { + const node = convertCptVariant({ + provider: "alibaba-cn", + official: true, + source: "test", + charges: { + prompt: { unit: "per_M_tokens", price: "2", currency: "CNY" }, + }, + tracks: null, + }); + expect(node).toBeNull(); + }); + + it("returns null when no billable charge exists", () => { + const node = convertCptVariant({ + provider: "x", + official: false, + source: "test", + charges: { cache_storage: { unit: "per_M_tokens_per_hour", price: "4.5" } }, + tracks: null, + }); + expect(node).toBeNull(); + }); +}); + +function claudeEntry(overrides?: Partial): CptModelEntry { + return { + slug: "anthropic/claude-sonnet-4-5", + model_name: "claude-sonnet-4-5", + vendor: "anthropic", + display_name: "Claude Sonnet 4.5", + aliases: ["claude-sonnet-4-5-20250929", "anthropic/claude-sonnet-4-5"], + family: "claude-sonnet", + model_type: "chat", + max_input_tokens: 200000, + max_output_tokens: 64000, + capabilities: { + function_calling: true, + prompt_caching: true, + vision: true, + reasoning: true, + structured_output: true, + }, + pricing: [ + claudeVariant(), + { + provider: "openrouter", + official: false, + source: "test", + charges: { + prompt: { unit: "per_M_tokens", price: "3.3" }, + completion: { unit: "per_M_tokens", price: "16.5" }, + }, + tracks: null, + }, + ], + ...overrides, + }; +} + +describe("convertCptModelEntry", () => { + it("uses the first official variant for top-level fields", () => { + const priceData = convertCptModelEntry(claudeEntry(), PROVIDERS); + expect(priceData).not.toBeNull(); + expect(priceData?.input_cost_per_token).toBeCloseTo(0.000003, 12); + expect(priceData?.official_pricing_provider).toBe("anthropic"); + expect(priceData?.mode).toBe("chat"); + expect(priceData?.display_name).toBe("Claude Sonnet 4.5"); + }); + + it("keeps per-provider pricing map with official flags", () => { + const priceData = convertCptModelEntry(claudeEntry(), PROVIDERS); + expect(Object.keys(priceData?.pricing ?? {})).toEqual(["anthropic", "openrouter"]); + expect(priceData?.pricing?.anthropic.official).toBe(true); + expect(priceData?.pricing?.openrouter.official).toBeUndefined(); + expect(priceData?.pricing?.openrouter.input_cost_per_token).toBeCloseTo(0.0000033, 12); + expect(priceData?.providers).toEqual(["anthropic", "openrouter"]); + }); + + it("carries cloud metadata: vendor, slug, aliases, icon, capabilities, limits", () => { + const priceData = convertCptModelEntry(claudeEntry(), PROVIDERS); + expect(priceData?.vendor).toBe("anthropic"); + expect(priceData?.slug).toBe("anthropic/claude-sonnet-4-5"); + expect(priceData?.aliases).toContain("claude-sonnet-4-5-20250929"); + expect(priceData?.vendor_icon).toBe("anthropic.svg"); + expect(priceData?.vendor_icon_mono).toBe(true); + expect(priceData?.supports_function_calling).toBe(true); + expect(priceData?.supports_tool_choice).toBe(true); + expect(priceData?.supports_prompt_caching).toBe(true); + expect(priceData?.supports_vision).toBe(true); + expect(priceData?.supports_response_schema).toBe(true); + expect(priceData?.max_input_tokens).toBe(200000); + expect(priceData?.max_output_tokens).toBe(64000); + expect(priceData?.model_family).toBe("claude-sonnet"); + }); + + it("falls back to the first variant when no official variant exists", () => { + const entry = claudeEntry({ + pricing: [ + { + provider: "openrouter", + official: false, + source: "test", + charges: { prompt: { unit: "per_M_tokens", price: "5" } }, + tracks: null, + }, + ], + }); + const priceData = convertCptModelEntry(entry, PROVIDERS); + expect(priceData?.input_cost_per_token).toBeCloseTo(0.000005, 12); + expect(priceData?.official_pricing_provider).toBeNull(); + }); + + it("maps model_type null to chat and image to image_generation", () => { + expect(convertCptModelEntry(claudeEntry({ model_type: null }), PROVIDERS)?.mode).toBe("chat"); + expect(convertCptModelEntry(claudeEntry({ model_type: "image" }), PROVIDERS)?.mode).toBe( + "image_generation" + ); + expect(convertCptModelEntry(claudeEntry({ model_type: "embedding" }), PROVIDERS)?.mode).toBe( + "embedding" + ); + }); + + it("returns null when every variant is unbillable", () => { + const entry = claudeEntry({ + pricing: [ + { + provider: "x", + official: true, + source: "test", + charges: {}, + tracks: null, + }, + ], + }); + expect(convertCptModelEntry(entry, PROVIDERS)).toBeNull(); + }); + + it("keys regional variants separately", () => { + const entry = claudeEntry({ + pricing: [ + claudeVariant(), + claudeVariant({ provider: "amazon-bedrock", official: false, region: "us-east-1" }), + ], + }); + const priceData = convertCptModelEntry(entry, PROVIDERS); + expect(Object.keys(priceData?.pricing ?? {})).toContain("amazon-bedrock@us-east-1"); + }); +}); + +describe("convertCptTable", () => { + function table(models: CptModelEntry[]): CptTable { + return { + schema: "cchp.pricing-table/v1", + version: "v1", + currency: "USD", + refreshed_at: "2026-07-01T00:00:00.000Z", + models, + providers: PROVIDERS, + }; + } + + it("keys models by bare model_name and aggregates vendors", () => { + const converted = convertCptTable( + table([ + claudeEntry(), + claudeEntry({ + slug: "openai/gpt-5.5", + model_name: "gpt-5.5", + vendor: "openai", + display_name: "GPT-5.5", + pricing: [ + { + provider: "openai", + official: true, + source: "test", + charges: { prompt: { unit: "per_M_tokens", price: "1.25" } }, + tracks: null, + }, + ], + }), + ]) + ); + + expect(Object.keys(converted.models).sort()).toEqual(["claude-sonnet-4-5", "gpt-5.5"]); + expect(converted.version).toBe("v1"); + expect(converted.vendors.map((v) => v.vendor).sort()).toEqual(["anthropic", "openai"]); + const anthropicVendor = converted.vendors.find((v) => v.vendor === "anthropic"); + expect(anthropicVendor?.name).toBe("Anthropic"); + expect(anthropicVendor?.icon).toBe("anthropic.svg"); + expect(anthropicVendor?.modelCount).toBe(1); + }); + + it("resolves bare-name collisions preferring official pricing over 'other' vendor", () => { + const officialEntry = claudeEntry({ + slug: "mistral/mistral-7b-instruct", + model_name: "mistral-7b-instruct", + vendor: "mistral", + display_name: "Mistral 7B", + pricing: [ + { + provider: "mistral", + official: true, + source: "test", + charges: { prompt: { unit: "per_M_tokens", price: "0.25" } }, + tracks: null, + }, + ], + }); + const otherEntry = claudeEntry({ + slug: "other/mistral-7b-instruct", + model_name: "mistral-7b-instruct", + vendor: "other", + display_name: "Mistral 7B (community)", + pricing: [ + { + provider: "openrouter", + official: false, + source: "test", + charges: { prompt: { unit: "per_M_tokens", price: "0.3" } }, + tracks: null, + }, + ], + }); + + const converted = convertCptTable(table([otherEntry, officialEntry])); + expect(Object.keys(converted.models)).toEqual(["mistral-7b-instruct"]); + expect(converted.models["mistral-7b-instruct"].vendor).toBe("mistral"); + }); + + it("skips dangerous model names", () => { + const converted = convertCptTable( + table([claudeEntry({ slug: "x/__proto__", model_name: "__proto__", vendor: "other" })]) + ); + expect(Object.keys(converted.models)).toEqual([]); + }); +}); diff --git a/tests/unit/price-sync/cpt-schema.test.ts b/tests/unit/price-sync/cpt-schema.test.ts new file mode 100644 index 000000000..8a4044528 --- /dev/null +++ b/tests/unit/price-sync/cpt-schema.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from "vitest"; +import { + CPT_SCHEMA_ID, + isCptTableLike, + parseCptTable, + parseCptTableValue, +} from "@/lib/price-sync/cpt-schema"; + +function validTable(overrides?: Record) { + return { + schema: CPT_SCHEMA_ID, + version: "abc123", + currency: "USD", + refreshed_at: "2026-07-01T00:00:00.000Z", + providers: { + anthropic: { name: "Anthropic", icon: "anthropic.svg", icon_mono: true }, + }, + models: [ + { + slug: "anthropic/claude-sonnet-4-5", + model_name: "claude-sonnet-4-5", + vendor: "anthropic", + display_name: "Claude Sonnet 4.5", + pricing: [ + { + provider: "anthropic", + official: true, + source: "test", + charges: { prompt: { unit: "per_M_tokens", price: "3" } }, + }, + ], + }, + ], + ...overrides, + }; +} + +describe("parseCptTable", () => { + it("parses a valid CPT v1 table", () => { + const result = parseCptTable(JSON.stringify(validTable())); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.data.version).toBe("abc123"); + expect(result.data.currency).toBe("USD"); + expect(result.data.models).toHaveLength(1); + expect(result.data.providers.anthropic?.name).toBe("Anthropic"); + }); + + it("rejects invalid JSON", () => { + const result = parseCptTable("{not json"); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toContain("JSON"); + }); + + it("rejects non-object roots", () => { + expect(parseCptTable("42").ok).toBe(false); + expect(parseCptTable("[]").ok).toBe(false); + }); + + it("rejects wrong schema id", () => { + const result = parseCptTable(JSON.stringify(validTable({ schema: "other/v2" }))); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toContain("schema"); + }); + + it("rejects missing models array", () => { + const result = parseCptTable(JSON.stringify(validTable({ models: undefined }))); + expect(result.ok).toBe(false); + }); + + it("rejects missing providers dictionary", () => { + const result = parseCptTable(JSON.stringify(validTable({ providers: undefined }))); + expect(result.ok).toBe(false); + }); + + it("skips malformed model entries but keeps valid ones", () => { + const table = validTable(); + table.models = [ + ...table.models, + { slug: "", model_name: "x", vendor: "v", display_name: "X", pricing: [] }, + { model_name: "no-slug", vendor: "v", display_name: "N", pricing: [] }, + "not-an-object", + ] as never; + const result = parseCptTableValue(table); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.data.models).toHaveLength(1); + }); + + it("rejects when all models are malformed", () => { + const result = parseCptTableValue(validTable({ models: [{ bogus: true }] })); + expect(result.ok).toBe(false); + }); + + it("drops dangerous provider keys", () => { + const table = validTable(); + (table.providers as Record).__proto__ = { name: "evil" }; + const result = parseCptTableValue(table); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(Object.keys(result.data.providers)).toEqual(["anthropic"]); + }); +}); + +describe("isCptTableLike", () => { + it("detects CPT payloads for upload sniffing", () => { + expect(isCptTableLike(validTable())).toBe(true); + expect(isCptTableLike({ "gpt-4": { mode: "chat" } })).toBe(false); + expect(isCptTableLike(null)).toBe(false); + expect(isCptTableLike([])).toBe(false); + }); +}); diff --git a/tests/unit/settings/prices/price-list-interactions.test.tsx b/tests/unit/settings/prices/price-list-interactions.test.tsx index 4876d5692..58d375970 100644 --- a/tests/unit/settings/prices/price-list-interactions.test.tsx +++ b/tests/unit/settings/prices/price-list-interactions.test.tsx @@ -54,6 +54,40 @@ function setReactInputValue(input: HTMLInputElement, value: string) { input.dispatchEvent(new Event("change", { bubbles: true })); } +/** 按 URL 路由的 fetch mock:/api/prices/vendors 返回 vendor 汇总,其余返回价格数据 */ +function makeRoutedFetchMock(pricePayload: unknown) { + const vendorsPayload = { + ok: true, + data: { + vendors: [ + { vendor: "openai", name: "OpenAI", icon: "openai.svg", iconMono: true, modelCount: 10 }, + { + vendor: "anthropic", + name: "Anthropic", + icon: "anthropic.svg", + iconMono: true, + modelCount: 8, + }, + ], + version: "test", + }, + }; + return vi.fn(async (input: unknown) => { + const url = String(input); + if (url.includes("/api/prices/vendors")) { + return { json: async () => vendorsPayload }; + } + return { json: async () => pricePayload }; + }); +} + +/** 过滤出 /api/prices 数据请求(排除 vendors 请求) */ +function priceCalls(fetchMock: ReturnType): string[] { + return fetchMock.mock.calls + .map((call) => String(call[0])) + .filter((url) => url.includes("/api/prices") && !url.includes("/api/prices/vendors")); +} + describe("PriceList: 交互与数据刷新", () => { const messages = loadMessages(); const now = new Date("2026-01-01T00:00:00.000Z"); @@ -89,11 +123,9 @@ describe("PriceList: 交互与数据刷新", () => { }); test("点击筛选按钮应触发拉取,并携带对应 query 参数", async () => { - const fetchMock = vi.fn().mockResolvedValue({ - json: async () => ({ - ok: true, - data: { data: [baseModel], total: 1, page: 1, pageSize: 50 }, - }), + const fetchMock = makeRoutedFetchMock({ + ok: true, + data: { data: [baseModel], total: 1, page: 1, pageSize: 50 }, }); // eslint-disable-next-line @typescript-eslint/no-explicit-any globalThis.fetch = fetchMock as any; @@ -107,11 +139,17 @@ describe("PriceList: 交互与数据刷新", () => { initialPageSize={20} initialSearchTerm="" initialSourceFilter="" - initialLitellmProviderFilter="" + initialVendorFilter="" /> ); + // 等待 vendors 列表加载后按钮渲染 + await act(async () => { + await flushPromises(); + await flushPromises(); + }); + const openaiFilter = Array.from(document.querySelectorAll("button")).find((el) => (el.textContent || "").includes("OpenAI") ); @@ -123,9 +161,9 @@ describe("PriceList: 交互与数据刷新", () => { await flushPromises(); }); - expect(fetchMock).toHaveBeenCalled(); - const firstUrl = fetchMock.mock.calls[0][0] as string; - expect(firstUrl).toContain("litellmProvider=openai"); + expect(priceCalls(fetchMock).length).toBe(1); + const firstUrl = priceCalls(fetchMock)[0]; + expect(firstUrl).toContain("vendor=openai"); await act(async () => { openaiFilter?.dispatchEvent(new MouseEvent("click", { bubbles: true })); @@ -133,19 +171,17 @@ describe("PriceList: 交互与数据刷新", () => { await flushPromises(); }); - expect(fetchMock).toHaveBeenCalledTimes(2); - const secondUrl = fetchMock.mock.calls[1][0] as string; - expect(secondUrl).not.toContain("litellmProvider=openai"); + expect(priceCalls(fetchMock).length).toBe(2); + const secondUrl = priceCalls(fetchMock)[1]; + expect(secondUrl).not.toContain("vendor=openai"); unmount(); }); test("点击 All 应清空筛选并触发拉取", async () => { - const fetchMock = vi.fn().mockResolvedValue({ - json: async () => ({ - ok: true, - data: { data: [baseModel], total: 1, page: 1, pageSize: 20 }, - }), + const fetchMock = makeRoutedFetchMock({ + ok: true, + data: { data: [baseModel], total: 1, page: 1, pageSize: 20 }, }); // eslint-disable-next-line @typescript-eslint/no-explicit-any globalThis.fetch = fetchMock as any; @@ -159,7 +195,7 @@ describe("PriceList: 交互与数据刷新", () => { initialPageSize={20} initialSearchTerm="" initialSourceFilter="" - initialLitellmProviderFilter="openai" + initialVendorFilter="openai" /> ); @@ -175,9 +211,9 @@ describe("PriceList: 交互与数据刷新", () => { await flushPromises(); }); - expect(fetchMock).toHaveBeenCalled(); - const url = fetchMock.mock.calls[0][0] as string; - expect(url).not.toContain("litellmProvider=openai"); + expect(priceCalls(fetchMock).length).toBeGreaterThan(0); + const url = priceCalls(fetchMock)[0]; + expect(url).not.toContain("vendor=openai"); unmount(); }); @@ -189,7 +225,7 @@ describe("PriceList: 交互与数据刷新", () => { data: { data: [page2Model], total: 60, page: 2, pageSize: 50 }, }; - const fetchMock = vi.fn().mockResolvedValue({ json: async () => page2 }); + const fetchMock = makeRoutedFetchMock(page2); // eslint-disable-next-line @typescript-eslint/no-explicit-any globalThis.fetch = fetchMock as any; @@ -202,7 +238,7 @@ describe("PriceList: 交互与数据刷新", () => { initialPageSize={50} initialSearchTerm="" initialSourceFilter="" - initialLitellmProviderFilter="" + initialVendorFilter="" /> ); @@ -219,8 +255,8 @@ describe("PriceList: 交互与数据刷新", () => { await flushPromises(); }); - expect(fetchMock).toHaveBeenCalled(); - const url = fetchMock.mock.calls[0][0] as string; + expect(priceCalls(fetchMock).length).toBeGreaterThan(0); + const url = priceCalls(fetchMock)[0]; expect(url).toContain("page=2"); expect(document.body.textContent).toContain("page-2-model"); @@ -229,11 +265,9 @@ describe("PriceList: 交互与数据刷新", () => { }); test("页面大小:切换 pageSize 应重新计算页码并重新请求", async () => { - const fetchMock = vi.fn().mockResolvedValue({ - json: async () => ({ - ok: true, - data: { data: [baseModel], total: 60, page: 2, pageSize: 50 }, - }), + const fetchMock = makeRoutedFetchMock({ + ok: true, + data: { data: [baseModel], total: 60, page: 2, pageSize: 50 }, }); // eslint-disable-next-line @typescript-eslint/no-explicit-any globalThis.fetch = fetchMock as any; @@ -247,7 +281,7 @@ describe("PriceList: 交互与数据刷新", () => { initialPageSize={20} initialSearchTerm="" initialSourceFilter="" - initialLitellmProviderFilter="" + initialVendorFilter="" /> ); @@ -274,8 +308,8 @@ describe("PriceList: 交互与数据刷新", () => { await flushPromises(); }); - expect(fetchMock).toHaveBeenCalled(); - const url = fetchMock.mock.calls[0][0] as string; + expect(priceCalls(fetchMock).length).toBeGreaterThan(0); + const url = priceCalls(fetchMock)[0]; expect(url).toContain("pageSize=50"); expect(url).toContain("page=2"); @@ -303,7 +337,7 @@ describe("PriceList: 交互与数据刷新", () => { initialPageSize={50} initialSearchTerm="" initialSourceFilter="" - initialLitellmProviderFilter="" + initialVendorFilter="" /> ); @@ -326,8 +360,8 @@ describe("PriceList: 交互与数据刷新", () => { await Promise.resolve(); }); - expect(fetchMock).toHaveBeenCalled(); - const url = fetchMock.mock.calls[0][0] as string; + expect(priceCalls(fetchMock).length).toBeGreaterThan(0); + const url = priceCalls(fetchMock)[0]; expect(url).toContain("search=gpt"); expect(url).toContain("page=1"); @@ -353,7 +387,7 @@ describe("PriceList: 交互与数据刷新", () => { initialPageSize={50} initialSearchTerm="" initialSourceFilter="" - initialLitellmProviderFilter="" + initialVendorFilter="" /> ); diff --git a/tests/unit/settings/prices/price-list-multi-provider-ui.test.tsx b/tests/unit/settings/prices/price-list-multi-provider-ui.test.tsx index dc4efcc12..ed807dcc4 100644 --- a/tests/unit/settings/prices/price-list-multi-provider-ui.test.tsx +++ b/tests/unit/settings/prices/price-list-multi-provider-ui.test.tsx @@ -29,7 +29,7 @@ function render(node: ReactNode) { } describe("PriceList multi-provider pricing", () => { - test("renders a Multi badge when a model contains multiple provider pricing nodes", () => { + test("renders a multi-source badge and official pricing info for cloud rows", () => { const messages = loadMessages(); const now = new Date("2026-03-06T00:00:00.000Z"); @@ -41,9 +41,12 @@ describe("PriceList multi-provider pricing", () => { mode: "responses", display_name: "GPT-5.5", model_family: "gpt", - litellm_provider: "chatgpt", + vendor: "openai", + slug: "openai/gpt-5.5", + official_pricing_provider: "openai", pricing: { openai: { + official: true, input_cost_per_token: 0.0000025, output_cost_per_token: 0.000015, }, @@ -53,7 +56,7 @@ describe("PriceList multi-provider pricing", () => { }, }, }, - source: "litellm", + source: "cloud", createdAt: now, updatedAt: now, }, @@ -68,12 +71,16 @@ describe("PriceList multi-provider pricing", () => { initialPageSize={50} initialSearchTerm="" initialSourceFilter="" - initialLitellmProviderFilter="" + initialVendorFilter="" /> ); - expect(document.body.textContent).toContain("Multi"); + // 多来源徽标(badges.multiWithCount)与 vendor 徽标 + expect(document.body.textContent).toContain("2 sources"); + expect(document.body.textContent).toContain("openai"); + // 官方价徽标(badges.official) + expect(document.body.textContent).toContain("Official"); expect(document.body.textContent).toContain("$2.50/M"); expect(document.body.textContent).toContain("$15.00/M"); unmount(); From 7b0dc5d81eca16183be6ff3e06a5d861cf08e27f Mon Sep 17 00:00:00 2001 From: ding113 Date: Sun, 5 Jul 2026 10:16:53 -0700 Subject: [PATCH 02/10] fix(price-sync): prevent mass deletion on empty cloud table sync Empty converted model set now fails fast before reaching deleteCloudPricesNotIn, which previously cleared all non-manual rows when the keep list was empty. Catalog modelCount now records the actual non-manual row count after sync instead of the cloud total, preventing permanent version short-circuit mismatch when manual conflicts skip rows. Catalog read orders by descending id for deterministic latest-row selection under concurrent sync races. --- src/lib/price-sync/cloud-price-updater.ts | 15 +++- src/repository/cloud-pricing-catalog.ts | 9 +- src/repository/model-price.ts | 5 +- .../price-sync/cloud-price-updater.test.ts | 84 +++++++++++++++++++ 4 files changed, 107 insertions(+), 6 deletions(-) diff --git a/src/lib/price-sync/cloud-price-updater.ts b/src/lib/price-sync/cloud-price-updater.ts index 2187bec68..656542965 100644 --- a/src/lib/price-sync/cloud-price-updater.ts +++ b/src/lib/price-sync/cloud-price-updater.ts @@ -30,6 +30,11 @@ export async function applyConvertedCloudPriceTable( converted: ConvertedCptTable, overwriteManual?: string[] ): Promise> { + // 转换结果为空时判为失败:落到 deleteCloudPricesNotIn([]) 会清空全部非 manual 行 + if (Object.keys(converted.models).length === 0) { + return { ok: false, error: "云端价格表转换结果为空模型集,跳过同步以避免误删现有价格" }; + } + try { const { processPriceTableInternal } = await import("@/actions/model-prices"); const jsonContent = JSON.stringify(converted.models); @@ -56,14 +61,20 @@ export async function applyConvertedCloudPriceTable( } try { - const { upsertCloudPricingCatalog } = await import("@/repository/cloud-pricing-catalog"); + const [{ upsertCloudPricingCatalog }, { countCloudModelPrices }] = await Promise.all([ + import("@/repository/cloud-pricing-catalog"), + import("@/repository/model-price"), + ]); + // 记录同步后的实际非 manual 行数:manual 冲突跳过/写入失败的模型不落库, + // 若直接记云端全量数,版本短路的行数比对会永久失配 + const cloudRowCount = await countCloudModelPrices(); await upsertCloudPricingCatalog({ version: converted.version, currency: converted.currency, refreshedAt: converted.refreshedAt || null, providers: converted.providers, vendors: converted.vendors, - modelCount: Object.keys(converted.models).length, + modelCount: cloudRowCount, }); } catch (error) { logger.warn("[PriceSync] Failed to persist cloud pricing catalog", { diff --git a/src/repository/cloud-pricing-catalog.ts b/src/repository/cloud-pricing-catalog.ts index 99ebd8c75..b768edb63 100644 --- a/src/repository/cloud-pricing-catalog.ts +++ b/src/repository/cloud-pricing-catalog.ts @@ -1,6 +1,6 @@ "use server"; -import { sql } from "drizzle-orm"; +import { desc, sql } from "drizzle-orm"; import { db } from "@/drizzle/db"; import { cloudPricingCatalog } from "@/drizzle/schema"; import { logger } from "@/lib/logger"; @@ -44,7 +44,12 @@ export async function upsertCloudPricingCatalog(input: CloudPricingCatalogInput) export async function getCloudPricingCatalog(): Promise { try { - const [row] = await db.select().from(cloudPricingCatalog).limit(1); + // 并发同步竞态下可能残留多行,固定取最新一条保证读取确定性 + const [row] = await db + .select() + .from(cloudPricingCatalog) + .orderBy(desc(cloudPricingCatalog.id)) + .limit(1); if (!row) return null; return { version: row.version, diff --git a/src/repository/model-price.ts b/src/repository/model-price.ts index 6e7a585e8..f85dd7f4e 100644 --- a/src/repository/model-price.ts +++ b/src/repository/model-price.ts @@ -390,11 +390,12 @@ export async function findAllManualPrices(): Promise> { * @returns 删除的行数 */ export async function deleteCloudPricesNotIn(keepModelNames: string[]): Promise { - const keep = keepModelNames.length > 0 ? keepModelNames : [""]; + // 空保留列表视为无效输入直接跳过:否则等同于清空全部非 manual 行 + if (keepModelNames.length === 0) return 0; const result = await db.execute(sql` DELETE FROM model_prices WHERE source <> 'manual' - AND NOT (model_name = ANY(${keep})) + AND NOT (model_name = ANY(${keepModelNames})) `); const count = (result as unknown as { count?: number }).count; return typeof count === "number" ? count : 0; diff --git a/tests/unit/price-sync/cloud-price-updater.test.ts b/tests/unit/price-sync/cloud-price-updater.test.ts index d2b5f3439..7a8979019 100644 --- a/tests/unit/price-sync/cloud-price-updater.test.ts +++ b/tests/unit/price-sync/cloud-price-updater.test.ts @@ -208,6 +208,8 @@ describe("syncCloudPriceTableToDatabase", () => { total: 1, }, } as any); + const { countCloudModelPrices } = await import("@/repository/model-price"); + vi.mocked(countCloudModelPrices).mockResolvedValue(1); const { syncCloudPriceTableToDatabase } = await import("@/lib/price-sync/cloud-price-updater"); const result = await syncCloudPriceTableToDatabase(); @@ -229,6 +231,88 @@ describe("syncCloudPriceTableToDatabase", () => { ); }); + it("fails without deleting rows when all models convert to empty", async () => { + // 唯一模型只有 CNY 报价 -> convertCptVariant 返回 null -> converted.models 为空 + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: true, + status: 200, + text: async () => + JSON.stringify({ + schema: "cchp.pricing-table/v1", + version: "cny-only", + currency: "USD", + refreshed_at: "2026-07-01T00:00:00.000Z", + providers: { alibaba: { name: "Alibaba" } }, + models: [ + { + slug: "alibaba/qwen-max-cn", + model_name: "qwen-max-cn", + vendor: "alibaba", + display_name: "Qwen Max CN", + model_type: "chat", + endpoints: { inbound: ["openai-chat"], outbound: ["openai-chat"] }, + pricing: [ + { + provider: "alibaba-cn", + official: true, + source: "test", + charges: { prompt: { unit: "per_M_tokens", price: "10", currency: "CNY" } }, + }, + ], + }, + ], + }), + })) + ); + + const { syncCloudPriceTableToDatabase } = await import("@/lib/price-sync/cloud-price-updater"); + const result = await syncCloudPriceTableToDatabase(); + + expect(result.ok).toBe(false); + const { processPriceTableInternal } = await import("@/actions/model-prices"); + expect(processPriceTableInternal).not.toHaveBeenCalled(); + const { deleteCloudPricesNotIn } = await import("@/repository/model-price"); + expect(vi.mocked(deleteCloudPricesNotIn)).not.toHaveBeenCalled(); + }); + + it("records actual non-manual row count in catalog when manual conflicts are skipped", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: true, + status: 200, + text: async () => buildCptJson(), + })) + ); + + const { processPriceTableInternal } = await import("@/actions/model-prices"); + vi.mocked(processPriceTableInternal).mockResolvedValue({ + ok: true, + data: { + added: [], + updated: [], + unchanged: [], + failed: [], + total: 1, + skippedConflicts: ["m1"], + }, + } as any); + // m1 与本地 manual 冲突被跳过,库内非 manual 行数为 0(而非云端全量 1) + const { countCloudModelPrices } = await import("@/repository/model-price"); + vi.mocked(countCloudModelPrices).mockResolvedValue(0); + + const { syncCloudPriceTableToDatabase } = await import("@/lib/price-sync/cloud-price-updater"); + const result = await syncCloudPriceTableToDatabase(); + + expect(result.ok).toBe(true); + const { upsertCloudPricingCatalog } = await import("@/repository/cloud-pricing-catalog"); + expect(vi.mocked(upsertCloudPricingCatalog)).toHaveBeenCalledWith( + expect.objectContaining({ modelCount: 0 }) + ); + }); + it("skips write when version fingerprint and row count are unchanged", async () => { vi.stubGlobal( "fetch", From d43ac24d48c593a7e72712fda823f1a6045b2f5f Mon Sep 17 00:00:00 2001 From: ding113 Date: Sun, 5 Jul 2026 10:16:53 -0700 Subject: [PATCH 03/10] fix(price-sync): reject non-USD and negative charges in CPT tiers Base price derivation for tier tracks now mirrors the main base price loop: non-USD currency charges and negative prices return null instead of producing incomparable or invalid billing fields. --- src/lib/price-sync/cpt-convert.ts | 7 +++++- tests/unit/price-sync/cpt-convert.test.ts | 28 +++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/lib/price-sync/cpt-convert.ts b/src/lib/price-sync/cpt-convert.ts index 51e33357c..58476c9ef 100644 --- a/src/lib/price-sync/cpt-convert.ts +++ b/src/lib/price-sync/cpt-convert.ts @@ -226,7 +226,12 @@ export function convertCptVariant(variant: CptPricingVariant): Record { const charge = charges[chargeKey]; if (!charge) return null; - return parseDecimal(charge.price); + // 与基础价循环同口径:非 USD 报价与内部计费不可比,负数价格拒绝 + if (typeof charge.currency === "string" && charge.currency && charge.currency !== "USD") { + return null; + } + const price = parseDecimal(charge.price); + return price !== null && price >= 0 ? price : null; }; // 基础价:base price x 默认轨道 factor(无默认轨道时 factor=1) diff --git a/tests/unit/price-sync/cpt-convert.test.ts b/tests/unit/price-sync/cpt-convert.test.ts index dc65973a8..008faa9bc 100644 --- a/tests/unit/price-sync/cpt-convert.test.ts +++ b/tests/unit/price-sync/cpt-convert.test.ts @@ -113,6 +113,34 @@ describe("convertCptVariant", () => { expect(node?.output_cost_per_token_above_272k_tokens).toBeCloseTo(0.00002, 12); }); + it("does not derive tier fields from non-USD or negative base charges", () => { + const node = convertCptVariant({ + provider: "alibaba-cn", + official: true, + source: "test", + charges: { + prompt: { unit: "per_M_tokens", price: "10", currency: "CNY" }, + cache_read: { unit: "per_M_tokens", price: "-1" }, + completion: { unit: "per_M_tokens", price: "40" }, + }, + tracks: [ + { + label: ">200K context", + factor: "1", + charge_factors: { prompt: "2", completion: "1.5", cache_read: "2" }, + triggers: [{ kind: "input_tokens_above", threshold: 200000 }], + }, + { label: "standard", factor: "1", triggers: [] }, + ], + }); + + // 基础价与分层价同口径:CNY / 负数维度均不产生计费字段 + expect(node?.input_cost_per_token).toBeUndefined(); + expect(node?.input_cost_per_token_above_200k_tokens).toBeUndefined(); + expect(node?.cache_read_input_token_cost_above_200k_tokens).toBeUndefined(); + expect(node?.output_cost_per_token_above_200k_tokens).toBeCloseTo(0.00006, 12); + }); + it("maps priority service tier tracks to priority fields", () => { const node = convertCptVariant({ provider: "openai", From 1961fe33570db40c7a28f626b9f0c8167e796ad3 Mon Sep 17 00:00:00 2001 From: ding113 Date: Sun, 5 Jul 2026 10:16:54 -0700 Subject: [PATCH 04/10] fix(pricing): skip nodes without valid price data in resolution Both resolveCloudOfficial and resolveDetailedFallback now validate that a pricing node carries valid price data before selecting it. Previously a metadata-only node (e.g. official flag without prices) could be selected, leaving top-level billing fields empty. --- src/lib/utils/pricing-resolution.ts | 40 +++++++++++-------- .../pricing-resolution-cloud-official.test.ts | 29 ++++++++++++-- 2 files changed, 48 insertions(+), 21 deletions(-) diff --git a/src/lib/utils/pricing-resolution.ts b/src/lib/utils/pricing-resolution.ts index 115e75032..b521f6a16 100644 --- a/src/lib/utils/pricing-resolution.ts +++ b/src/lib/utils/pricing-resolution.ts @@ -399,7 +399,9 @@ function resolveCloudOfficial(candidate: ModelRecordCandidate): ResolvedPricing for (const key of officialKeys) { const pricingNode = pricingMap[key]; - if (!pricingNode) continue; + // 校验节点自身价格:merge 后的整表校验会被 pricing 映射里其他节点"带过", + // 选中无价格节点会让顶层计费字段为空 + if (!pricingNode || !hasValidPriceData(pricingNode as ModelPriceData)) continue; const mergedPriceData = mergePriceData(candidate.record.priceData, pricingNode, key); if (!hasValidPriceData(mergedPriceData)) continue; @@ -421,31 +423,35 @@ function resolveDetailedFallback(candidate: ModelRecordCandidate): ResolvedPrici return null; } - // 官方节点优先,再按明细字段数排序 + // 官方节点优先,再按明细字段数排序;首选节点数据无效时继续尝试后续节点 const keys = Object.keys(pricingMap).sort((a, b) => { const officialA = pricingMap[a]?.official === true ? 0 : 1; const officialB = pricingMap[b]?.official === true ? 0 : 1; if (officialA !== officialB) return officialA - officialB; return compareDetailKeys(a, b, pricingMap); }); - const selectedKey = keys[0]; - if (!selectedKey) { - return null; - } - const pricingNode = pricingMap[selectedKey]; - const mergedPriceData = mergePriceData(candidate.record.priceData, pricingNode, selectedKey); - if (!hasValidPriceData(mergedPriceData)) { - return null; + for (const selectedKey of keys) { + const pricingNode = pricingMap[selectedKey]; + // 同 resolveCloudOfficial:节点自身必须携带有效价格,否则继续尝试后续节点 + if (!pricingNode || !hasValidPriceData(pricingNode as ModelPriceData)) { + continue; + } + const mergedPriceData = mergePriceData(candidate.record.priceData, pricingNode, selectedKey); + if (!hasValidPriceData(mergedPriceData)) { + continue; + } + + return { + resolvedModelName: candidate.modelName ?? candidate.record.modelName, + resolvedPricingProviderKey: selectedKey, + source: "priority_fallback", + priceData: mergedPriceData, + pricingNode, + }; } - return { - resolvedModelName: candidate.modelName ?? candidate.record.modelName, - resolvedPricingProviderKey: selectedKey, - source: "priority_fallback", - priceData: mergedPriceData, - pricingNode, - }; + return null; } function resolveTopLevel(candidate: ModelRecordCandidate): ResolvedPricing | null { diff --git a/tests/unit/lib/pricing-resolution-cloud-official.test.ts b/tests/unit/lib/pricing-resolution-cloud-official.test.ts index 812cefae5..e2f8c5e47 100644 --- a/tests/unit/lib/pricing-resolution-cloud-official.test.ts +++ b/tests/unit/lib/pricing-resolution-cloud-official.test.ts @@ -133,9 +133,9 @@ describe("resolvePricingForModelRecords - cloud official", () => { expect(resolved?.source).toBe("official_fallback"); }); - it("official-aware detail fallback prefers official nodes at equal detail", () => { + it("claims official=true nodes via cloud official even without vendor declarations", () => { const record = makeCloudRecord({ - // 无 vendor/官方声明,exact/official 键都不命中 -> 走 detail fallback + // 无 vendor/官方声明,exact/official 键都不命中,official=true 由 resolveCloudOfficial 兜住 pricing: { aaa: { input_cost_per_token: 0.000001, output_cost_per_token: 0.000002 }, zzz: { @@ -146,8 +146,6 @@ describe("resolvePricingForModelRecords - cloud official", () => { }, official_pricing_provider: undefined, }); - // 干掉 cloud_official 的 official 标志识别路径?不能——official=true 会先被 resolveCloudOfficial 命中。 - // 这里直接断言 cloud_official 生效即可。 const resolved = resolvePricingForModelRecords({ provider: null, primaryModelName: "mystery-model", @@ -157,5 +155,28 @@ describe("resolvePricingForModelRecords - cloud official", () => { }); expect(resolved?.resolvedPricingProviderKey).toBe("zzz"); + expect(resolved?.source).toBe("cloud_official"); + }); + + it("detail fallback skips official nodes without valid price data", () => { + const record = makeCloudRecord({ + // official 节点无任何有效价格字段:cloud_official 不命中, + // detail fallback 的官方优先排序也必须继续尝试后续有效节点 + pricing: { + aaa: { input_cost_per_token: 0.000001, output_cost_per_token: 0.000002 }, + zzz: { official: true, provider_model_id: "zzz-model" }, + }, + official_pricing_provider: undefined, + }); + const resolved = resolvePricingForModelRecords({ + provider: null, + primaryModelName: "mystery-model", + fallbackModelName: null, + primaryRecord: record, + fallbackRecord: null, + }); + + expect(resolved?.resolvedPricingProviderKey).toBe("aaa"); + expect(resolved?.source).toBe("priority_fallback"); }); }); From 3598ac60960fcc94e753848e75b08842f9b66e1e Mon Sep 17 00:00:00 2001 From: ding113 Date: Sun, 5 Jul 2026 10:16:54 -0700 Subject: [PATCH 05/10] fix(pricing): preserve non-host org prefixes in name matching buildModelNameFallbackCandidates no longer strips org prefixes that are not recognized host providers. Previously paths like Pro/deepseek-ai/DeepSeek-V3.2 would incorrectly generate deepseek-ai/DeepSeek-V3.2 as a fallback candidate. --- src/lib/utils/model-name-matching.ts | 9 +++------ tests/unit/lib/utils/model-name-matching.test.ts | 7 +++++++ 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/lib/utils/model-name-matching.ts b/src/lib/utils/model-name-matching.ts index 1ea340629..77d89ff07 100644 --- a/src/lib/utils/model-name-matching.ts +++ b/src/lib/utils/model-name-matching.ts @@ -27,17 +27,14 @@ export function buildModelNameFallbackCandidates(modelName: string): string[] { seeds.add(noSuffix); for (const seed of Array.from(seeds)) { - // "org/model":org 为托管商时跳过 org;否则同时保留完整段与最后一段 + // "org/model":org 为托管商时跳过 org;否则只保留完整段与最后一段 if (seed.includes("/")) { const firstSlash = seed.indexOf("/"); const org = seed.slice(0, firstSlash); - const rest = seed.slice(firstSlash + 1); if (isHostPrefix(org)) { - seeds.add(rest); + seeds.add(seed.slice(firstSlash + 1)); } - const lastSegment = seed.slice(seed.lastIndexOf("/") + 1); - seeds.add(lastSegment); - seeds.add(rest); + seeds.add(seed.slice(seed.lastIndexOf("/") + 1)); } } diff --git a/tests/unit/lib/utils/model-name-matching.test.ts b/tests/unit/lib/utils/model-name-matching.test.ts index ae4c09558..bf2d738ae 100644 --- a/tests/unit/lib/utils/model-name-matching.test.ts +++ b/tests/unit/lib/utils/model-name-matching.test.ts @@ -43,6 +43,13 @@ describe("buildModelNameFallbackCandidates", () => { expect(candidates).toContain("DeepSeek-V3.2"); }); + it("does not strip non-host org prefixes", () => { + // "Pro" 不是托管商,去 org 的中间形态不应成为候选 + const candidates = buildModelNameFallbackCandidates("Pro/deepseek-ai/DeepSeek-V3.2"); + expect(candidates).not.toContain("deepseek-ai/DeepSeek-V3.2"); + expect(candidates).not.toContain("deepseek-ai/deepseek-v3.2"); + }); + it("deduplicates candidates", () => { const candidates = buildModelNameFallbackCandidates("openai/gpt-5.5"); expect(new Set(candidates).size).toBe(candidates.length); From 05e39c436dfb382f7128969284695d11e95889a7 Mon Sep 17 00:00:00 2001 From: ding113 Date: Sun, 5 Jul 2026 10:16:56 -0700 Subject: [PATCH 06/10] refactor(vendor-icons): extract shared dash-prefix lookup Move the exact-match then longest-dash-prefix fallback logic into resolveByDashPrefix, shared by icon component resolution and cloud SVG file lookup. Normalize camelCase keys in vendor-icon-map.json to kebab-case to match the dash-prefix resolution convention. --- src/lib/model-vendor-icons.test.ts | 8 +++++++- src/lib/model-vendor-icons.tsx | 11 ++--------- src/lib/model-vendor/dash-prefix-lookup.ts | 12 ++++++++++++ src/lib/model-vendor/vendor-icon-files.ts | 11 ++--------- src/lib/model-vendor/vendor-icon-map.json | 3 +-- 5 files changed, 24 insertions(+), 21 deletions(-) create mode 100644 src/lib/model-vendor/dash-prefix-lookup.ts diff --git a/src/lib/model-vendor-icons.test.ts b/src/lib/model-vendor-icons.test.ts index feba1d9c4..b1b99775e 100644 --- a/src/lib/model-vendor-icons.test.ts +++ b/src/lib/model-vendor-icons.test.ts @@ -74,9 +74,15 @@ describe("getVendorIconComponent", () => { }); describe("getVendorEntry", () => { - it("keeps vendor slug and display name for unregistered vendors", () => { + it("resolves display name for registered vendors", () => { const entry = getVendorEntry("reka"); expect(entry.vendor).toBe("reka"); expect(entry.displayName).toBe("Reka"); }); + + it("keeps the slug as display name for unregistered vendors", () => { + const entry = getVendorEntry("definitely-unknown-vendor"); + expect(entry.vendor).toBe("definitely-unknown-vendor"); + expect(entry.displayName).toBe("definitely-unknown-vendor"); + }); }); diff --git a/src/lib/model-vendor-icons.tsx b/src/lib/model-vendor-icons.tsx index 7c3825a04..15c04d294 100644 --- a/src/lib/model-vendor-icons.tsx +++ b/src/lib/model-vendor-icons.tsx @@ -80,6 +80,7 @@ import { Yi, Zhipu, } from "@lobehub/icons"; +import { resolveByDashPrefix } from "@/lib/model-vendor/dash-prefix-lookup"; import { iconFileForVendor, type VendorIconFileEntry } from "@/lib/model-vendor/vendor-icon-files"; import { inferVendorFromModelName, @@ -186,15 +187,7 @@ const VENDOR_ICON_COMPONENTS: Record = { /** slug 精确命中 -> 最长 dash 前缀家族回退(与云端 icon 解析规则一致) */ export function getVendorIconComponent(slug: string): VendorIconComponent | null { - const key = slug.trim().toLowerCase(); - if (!key) return null; - if (VENDOR_ICON_COMPONENTS[key]) return VENDOR_ICON_COMPONENTS[key]; - let probe = key; - while (probe.includes("-")) { - probe = probe.slice(0, probe.lastIndexOf("-")); - if (VENDOR_ICON_COMPONENTS[probe]) return VENDOR_ICON_COMPONENTS[probe]; - } - return null; + return resolveByDashPrefix(slug, VENDOR_ICON_COMPONENTS); } export interface ModelVendorEntry { diff --git a/src/lib/model-vendor/dash-prefix-lookup.ts b/src/lib/model-vendor/dash-prefix-lookup.ts new file mode 100644 index 000000000..61f23563f --- /dev/null +++ b/src/lib/model-vendor/dash-prefix-lookup.ts @@ -0,0 +1,12 @@ +/** slug 精确命中 -> 逐段剥离末尾 dash 前缀回退(alibaba-coding-plan-cn -> alibaba);图标组件与云端 SVG 映射共用同一规则 */ +export function resolveByDashPrefix(slug: string, map: Record): T | null { + const key = slug.trim().toLowerCase(); + if (!key) return null; + if (map[key]) return map[key]; + let probe = key; + while (probe.includes("-")) { + probe = probe.slice(0, probe.lastIndexOf("-")); + if (map[probe]) return map[probe]; + } + return null; +} diff --git a/src/lib/model-vendor/vendor-icon-files.ts b/src/lib/model-vendor/vendor-icon-files.ts index 05233bc8e..7b24435e0 100644 --- a/src/lib/model-vendor/vendor-icon-files.ts +++ b/src/lib/model-vendor/vendor-icon-files.ts @@ -2,6 +2,7 @@ // vendor-icon-map.json is a verbatim copy of the cch-plus.com official website // icon map, so icons resolved here match the `icon` fields published in the // cloud pricing table (served at https://cch-plus.com/model-icons/). +import { resolveByDashPrefix } from "./dash-prefix-lookup"; import iconMap from "./vendor-icon-map.json"; export interface VendorIconFileEntry { @@ -20,15 +21,7 @@ export function cloudModelIconUrl(file: string): string { /** 精确命中 -> 最长前缀家族(alibaba-coding-plan-cn -> alibaba)回退;都没有返回 null */ export function iconFileForVendor(slug: string): VendorIconFileEntry | null { - const key = slug.trim().toLowerCase(); - if (!key) return null; - if (ICONS[key]) return ICONS[key]; - let probe = key; - while (probe.includes("-")) { - probe = probe.slice(0, probe.lastIndexOf("-")); - if (ICONS[probe]) return ICONS[probe]; - } - return null; + return resolveByDashPrefix(slug, ICONS); } /** 任意字符串 -> 确定性强调色(固定明度/彩度,色相走 hash),用于 monogram 兜底 */ diff --git a/src/lib/model-vendor/vendor-icon-map.json b/src/lib/model-vendor/vendor-icon-map.json index 9f628470c..426b4966b 100644 --- a/src/lib/model-vendor/vendor-icon-map.json +++ b/src/lib/model-vendor/vendor-icon-map.json @@ -94,10 +94,9 @@ "openai": { "file": "openai.svg", "mono": true }, "openchat": { "file": "openchat-color.svg", "mono": false }, "opencode": { "file": "opencode.svg", "mono": true }, + "opencode-coding-plan": { "file": "opencode.svg", "mono": true }, "opencode-go": { "file": "opencode.svg", "mono": true }, "opencode-zen": { "file": "opencode.svg", "mono": true }, - "opencodeCodingPlan": { "file": "opencode.svg", "mono": true }, - "opencodeZen": { "file": "opencode.svg", "mono": true }, "openrouter": { "file": "openrouter.svg", "mono": true }, "perplexity": { "file": "perplexity-color.svg", "mono": false }, "perplexity-agent": { "file": "perplexity-color.svg", "mono": false }, From 25029b6dc9dd32d98b11897dfa9d25fb9be7021d Mon Sep 17 00:00:00 2001 From: ding113 Date: Sun, 5 Jul 2026 10:16:56 -0700 Subject: [PATCH 07/10] fix(vendor-icons): retry icon load when remote file changes Track the failed file name instead of a boolean so that switching to a different icon URL reattempts the load rather than permanently showing the monogram fallback. --- src/components/customs/model-vendor-icon.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/components/customs/model-vendor-icon.tsx b/src/components/customs/model-vendor-icon.tsx index 392b972ab..92500ff18 100644 --- a/src/components/customs/model-vendor-icon.tsx +++ b/src/components/customs/model-vendor-icon.tsx @@ -41,8 +41,9 @@ function RemoteVendorIcon({ fallbackSeed: string; className: string; }) { - const [failed, setFailed] = useState(false); - if (failed) { + // 记录失败的具体文件而非布尔值:file 变化后自动重试新图标 + const [failedFile, setFailedFile] = useState(null); + if (failedFile === file) { return ; } return ( @@ -52,7 +53,7 @@ function RemoteVendorIcon({ alt="" aria-hidden="true" loading="lazy" - onError={() => setFailed(true)} + onError={() => setFailedFile(file)} className={`select-none ${mono ? "dark:invert" : ""} ${className}`} /> ); From 4c0ac7a343f6b83ead8ae4b2577c8001a36a0292 Mon Sep 17 00:00:00 2001 From: ding113 Date: Sun, 5 Jul 2026 10:16:56 -0700 Subject: [PATCH 08/10] fix(prices): refresh vendor filter list after price data changes The vendor summary list now reloads on the price-data-updated custom event, keeping filter buttons in sync after cloud sync or manual price edits. --- .../prices/_components/price-list.tsx | 33 +++++++++---------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/src/app/[locale]/settings/prices/_components/price-list.tsx b/src/app/[locale]/settings/prices/_components/price-list.tsx index 9ecad16bd..611d81a86 100644 --- a/src/app/[locale]/settings/prices/_components/price-list.tsx +++ b/src/app/[locale]/settings/prices/_components/price-list.tsx @@ -340,26 +340,25 @@ export function PriceList({ [debouncedSearchTerm, fetchPrices, pageSize, updateURL] ); - // 云端 vendor 汇总(筛选按钮数据源) - useEffect(() => { - let cancelled = false; - const loadVendors = async () => { - try { - const response = await fetch("/api/prices/vendors", { cache: "no-store" }); - const payload = await response.json(); - if (!cancelled && payload?.ok && Array.isArray(payload.data?.vendors)) { - setVendors(payload.data.vendors as CloudVendorSummary[]); - } - } catch (error) { - console.error("获取云端 vendor 列表失败:", error); + // 云端 vendor 汇总(筛选按钮数据源);同步/上传后随 price-data-updated 事件刷新 + const loadVendors = useCallback(async () => { + try { + const response = await fetch("/api/prices/vendors", { cache: "no-store" }); + const payload = await response.json(); + if (payload?.ok && Array.isArray(payload.data?.vendors)) { + setVendors(payload.data.vendors as CloudVendorSummary[]); } - }; - loadVendors(); - return () => { - cancelled = true; - }; + } catch (error) { + console.error("获取云端 vendor 列表失败:", error); + } }, []); + useEffect(() => { + loadVendors(); + window.addEventListener("price-data-updated", loadVendors); + return () => window.removeEventListener("price-data-updated", loadVendors); + }, [loadVendors]); + const quickVendors = vendors.slice(0, QUICK_VENDOR_BUTTON_COUNT); const moreVendors = vendors.slice(QUICK_VENDOR_BUTTON_COUNT); const activeVendorInMore = moreVendors.some((item) => item.vendor === vendorFilter); From 6f4c384b0b5e34fa86029360ac721110bd509029 Mon Sep 17 00:00:00 2001 From: ding113 Date: Sun, 5 Jul 2026 10:16:57 -0700 Subject: [PATCH 09/10] fix(i18n): correct prices table mode column label across locales Rename the column header from Type to Mode in all five locale files to match the field semantics. --- messages/en/settings/prices.json | 2 +- messages/ja/settings/prices.json | 2 +- messages/ru/settings/prices.json | 2 +- messages/zh-CN/settings/prices.json | 2 +- messages/zh-TW/settings/prices.json | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/messages/en/settings/prices.json b/messages/en/settings/prices.json index 01eac998a..a83362590 100644 --- a/messages/en/settings/prices.json +++ b/messages/en/settings/prices.json @@ -64,7 +64,7 @@ "outputPrice": "Output Price", "imagePrice": "Image Price", "provider": "Provider", - "mode": "Type", + "mode": "Mode", "cloud": "Cloud" }, "pagination": { diff --git a/messages/ja/settings/prices.json b/messages/ja/settings/prices.json index 801f8d057..0437a5f41 100644 --- a/messages/ja/settings/prices.json +++ b/messages/ja/settings/prices.json @@ -64,7 +64,7 @@ "outputPrice": "出力価格", "imagePrice": "画像価格", "provider": "プロバイダー", - "mode": "タイプ", + "mode": "モード", "cloud": "クラウド" }, "pagination": { diff --git a/messages/ru/settings/prices.json b/messages/ru/settings/prices.json index 1835b89e6..045c59556 100644 --- a/messages/ru/settings/prices.json +++ b/messages/ru/settings/prices.json @@ -64,7 +64,7 @@ "outputPrice": "Цена вывода", "imagePrice": "Цена изображения", "provider": "Поставщик", - "mode": "Тип", + "mode": "Режим", "cloud": "Облако" }, "pagination": { diff --git a/messages/zh-CN/settings/prices.json b/messages/zh-CN/settings/prices.json index 9f0c54abb..dd5df77d5 100644 --- a/messages/zh-CN/settings/prices.json +++ b/messages/zh-CN/settings/prices.json @@ -64,7 +64,7 @@ "outputPrice": "输出价格", "imagePrice": "图片价格", "provider": "供应商", - "mode": "类型", + "mode": "模式", "cloud": "云端" }, "pagination": { diff --git a/messages/zh-TW/settings/prices.json b/messages/zh-TW/settings/prices.json index f23fcd493..5ce602845 100644 --- a/messages/zh-TW/settings/prices.json +++ b/messages/zh-TW/settings/prices.json @@ -64,7 +64,7 @@ "outputPrice": "輸出價格", "imagePrice": "圖片價格", "provider": "供應商", - "mode": "類型", + "mode": "模式", "cloud": "雲端" }, "pagination": { From 617a69e9be34947944dac85ab3744f2a14ff5827 Mon Sep 17 00:00:00 2001 From: ding113 Date: Sun, 5 Jul 2026 10:16:57 -0700 Subject: [PATCH 10/10] test(price-sync): verify prototype pollution filtering via JSON.parse The previous test assigned __proto__ via object property which only mutates the prototype chain. Using JSON.parse produces a real own __proto__ property that actually exercises the sanitization logic. --- tests/unit/price-sync/cpt-schema.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/unit/price-sync/cpt-schema.test.ts b/tests/unit/price-sync/cpt-schema.test.ts index 8a4044528..beb777150 100644 --- a/tests/unit/price-sync/cpt-schema.test.ts +++ b/tests/unit/price-sync/cpt-schema.test.ts @@ -96,7 +96,10 @@ describe("parseCptTable", () => { it("drops dangerous provider keys", () => { const table = validTable(); - (table.providers as Record).__proto__ = { name: "evil" }; + // JSON.parse 才会产生自有 "__proto__" 属性;对象字面量赋值只改原型链,测不到过滤逻辑 + (table as Record).providers = JSON.parse( + '{"anthropic":{"name":"Anthropic"},"__proto__":{"name":"evil"},"constructor":{"name":"evil"},"prototype":{"name":"evil"}}' + ); const result = parseCptTableValue(table); expect(result.ok).toBe(true); if (!result.ok) return;