feat(logs): complete modern log metrics and filters (#686)
* feat(logs): complete modern log metrics and filters * fix(release): use GitHub-hosted runners (#685) * feat(logs): complete modern log metrics and filters * fix(logs): validate index ordering and tooltips
This commit is contained in:
1 parent
47e3838e0b
commit
37cd99fb01
23 files changed
+572
-34
No files matched your search
@@ -0,0 +1,25 @@
|
||||
package control
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gpt-load/internal/execution"
|
||||
)
|
||||
|
||||
func TestRequestLogQueryFiltersLogicalOperation(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, operation := range []execution.Operation{execution.OperationChatCompletion, execution.OperationWebSearch, execution.OperationResponsesRetrieve} {
|
||||
query, apiErr := parseRequestLogQuery("operation=" + string(operation))
|
||||
if apiErr != nil || query.Operation != operation {
|
||||
t.Fatalf("operation %q parsed as %#v, error = %v", operation, query.Operation, apiErr)
|
||||
}
|
||||
}
|
||||
for _, query := range []string{"operation=unknown", "operation=", "operation=web_search&operation=chat_completion"} {
|
||||
if _, apiErr := parseRequestLogQuery(query); apiErr == nil {
|
||||
t.Fatalf("query %q accepted", query)
|
||||
}
|
||||
}
|
||||
if requestLogQueryUsesInternalFields("operation=web_search") {
|
||||
t.Fatal("logical operation must remain available in self-scoped logs")
|
||||
}
|
||||
}
|
||||
@@ -349,7 +349,7 @@ func parseRequestLogQuery(rawQuery string) (requestlog.ListQuery, *app_errors.AP
|
||||
allowed := map[string]struct{}{
|
||||
"from_ms": {}, "to_ms": {}, "group_id": {}, "channel_id": {}, "credential_id": {},
|
||||
"client_model": {}, "upstream_model": {}, "model_consistency": {}, "access_key_id": {},
|
||||
"status": {}, "request_id": {}, "protocol": {}, "stream": {}, "final_status_code": {},
|
||||
"status": {}, "request_id": {}, "protocol": {}, "operation": {}, "stream": {}, "final_status_code": {},
|
||||
"usage_state": {}, "cost_state": {}, "pricing_completeness": {}, "cache_present": {},
|
||||
"attempt_status_code": {}, "failure_category": {}, "error_code": {},
|
||||
"retry_state": {}, "retry_count_min": {}, "retry_count_max": {},
|
||||
@@ -443,6 +443,13 @@ func parseRequestLogQuery(rawQuery string) (requestlog.ListQuery, *app_errors.AP
|
||||
}
|
||||
query.Protocol = parsed
|
||||
}
|
||||
if value, ok := singleQueryValue(values, "operation"); ok {
|
||||
operation := execution.Operation(value)
|
||||
if !operation.Valid() {
|
||||
return requestlog.ListQuery{}, app_errors.ErrValidation
|
||||
}
|
||||
query.Operation = operation
|
||||
}
|
||||
if value, ok := singleQueryValue(values, "stream"); ok {
|
||||
parsed, valid := parseRequestLogBool(value)
|
||||
if !valid {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package requestlog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gpt-load/internal/execution"
|
||||
)
|
||||
|
||||
func TestServiceListFiltersLogicalOperationBeforePagination(t *testing.T) {
|
||||
db := openRequestLogQueryDB(t)
|
||||
completed := time.Now()
|
||||
for index, operation := range []execution.Operation{execution.OperationWebSearch, execution.OperationChatCompletion, execution.OperationWebSearch} {
|
||||
id := []string{"00000000-0000-4000-8000-000000000701", "00000000-0000-4000-8000-000000000702", "00000000-0000-4000-8000-000000000703"}[index]
|
||||
row := requestLogQueryRow(id, completed, 1, "model", nil)
|
||||
row.Operation = string(operation)
|
||||
createRequestLogQueryRow(t, db, row)
|
||||
}
|
||||
service := newRequestLogTestService(db)
|
||||
query := ListQuery{Operation: execution.OperationWebSearch, Limit: 1}
|
||||
page, err := service.List(context.Background(), query)
|
||||
if err != nil || len(page.Items) != 1 || page.Items[0].RequestID != "00000000-0000-4000-8000-000000000703" || page.NextCursor == nil {
|
||||
t.Fatalf("first page = %#v, error = %v", page, err)
|
||||
}
|
||||
query.Cursor = page.NextCursor
|
||||
page, err = service.List(context.Background(), query)
|
||||
if err != nil || len(page.Items) != 1 || page.Items[0].RequestID != "00000000-0000-4000-8000-000000000701" || page.NextCursor != nil {
|
||||
t.Fatalf("second page = %#v, error = %v", page, err)
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,9 @@ func (service *Service) List(ctx context.Context, input ListQuery) (Page, error)
|
||||
if input.Protocol != "" {
|
||||
query = query.Where("protocol = ?", input.Protocol)
|
||||
}
|
||||
if input.Operation != "" {
|
||||
query = query.Where("operation = ?", input.Operation)
|
||||
}
|
||||
if input.Stream != nil {
|
||||
query = query.Where("stream = ?", *input.Stream)
|
||||
}
|
||||
|
||||
@@ -82,6 +82,7 @@ type ListQuery struct {
|
||||
Status telemetry.RequestStatus
|
||||
RequestID string
|
||||
Protocol protocol.Protocol
|
||||
Operation execution.Operation
|
||||
Stream *bool
|
||||
FinalStatusCode *int
|
||||
UsageState usage.State
|
||||
|
||||
@@ -123,7 +123,7 @@ func TestExternalDatabaseLifecycle(t *testing.T) {
|
||||
if err := db.Table("schema_migrations").Order("id").Pluck("id", &migrationIDs).Error; err != nil {
|
||||
t.Fatalf("read migration ledger: %v", err)
|
||||
}
|
||||
if len(migrationIDs) != 16 || migrationIDs[0] != "0001_initial" ||
|
||||
if len(migrationIDs) != 17 || migrationIDs[0] != "0001_initial" ||
|
||||
migrationIDs[1] != "0002_access_key_cost_limits" ||
|
||||
migrationIDs[2] != "0003_remove_observation_fresh_until" ||
|
||||
migrationIDs[3] != "0004_usage_stats_group_activity_index" ||
|
||||
@@ -131,8 +131,8 @@ func TestExternalDatabaseLifecycle(t *testing.T) {
|
||||
migrationIDs[5] != "0006_error_decision" ||
|
||||
migrationIDs[6] != "0007_access_key_lifecycle" ||
|
||||
migrationIDs[7] != "0008_remove_inject_usage_options" ||
|
||||
migrationIDs[8] != "0009_price_multipliers" || migrationIDs[9] != "0010_model_cooldown" || migrationIDs[10] != "0011_custom_access_keys" || migrationIDs[11] != "0012_access_key_mask_prefix" || migrationIDs[12] != "0013_validation_protocol" || migrationIDs[13] != "0014_affinity_kind" || migrationIDs[14] != "0015_group_usage_index" || migrationIDs[15] != "0016_credential_quota_history" {
|
||||
t.Fatalf("migration ledger = %v, want complete 0001-0016 chain", migrationIDs)
|
||||
migrationIDs[8] != "0009_price_multipliers" || migrationIDs[9] != "0010_model_cooldown" || migrationIDs[10] != "0011_custom_access_keys" || migrationIDs[11] != "0012_access_key_mask_prefix" || migrationIDs[12] != "0013_validation_protocol" || migrationIDs[13] != "0014_affinity_kind" || migrationIDs[14] != "0015_group_usage_index" || migrationIDs[15] != "0016_credential_quota_history" || migrationIDs[16] != "0017_request_log_operation_index" {
|
||||
t.Fatalf("migration ledger = %v, want complete 0001-0017 chain", migrationIDs)
|
||||
}
|
||||
if !db.Migrator().HasIndex("usage_stats", "idx_usage_stats_group_bucket") {
|
||||
t.Fatal("usage_stats group activity index is missing")
|
||||
|
||||
@@ -673,6 +673,7 @@ func TestAutoMigrateCreatesUsageJournalAndMigrationLedger(t *testing.T) {
|
||||
"0014_affinity_kind",
|
||||
"0015_group_usage_index",
|
||||
"0016_credential_quota_history",
|
||||
"0017_request_log_operation_index",
|
||||
}
|
||||
if !reflect.DeepEqual(migrationIDs, wantMigrationIDs) {
|
||||
t.Fatalf("schema_migrations IDs = %v, want %v", migrationIDs, wantMigrationIDs)
|
||||
|
||||
@@ -107,6 +107,7 @@ var migrations = []migration{
|
||||
{ID: migrationfiles.ID0014, Up: migrationfiles.Up0014, Validate: migrationfiles.Validate0014, ValidateRecoverable: migrationfiles.ValidateRecoverable0014},
|
||||
{ID: migrationfiles.ID0015, Up: migrationfiles.Up0015, Validate: migrationfiles.Validate0015, ValidateRecoverable: migrationfiles.ValidateRecoverable0015},
|
||||
{ID: migrationfiles.ID0016, Up: migrationfiles.Up0016, Validate: migrationfiles.Validate0016, ValidateRecoverable: migrationfiles.ValidateRecoverable0016},
|
||||
{ID: migrationfiles.ID0017, Up: migrationfiles.Up0017, Validate: migrationfiles.Validate0017, ValidateRecoverable: migrationfiles.ValidateRecoverable0017},
|
||||
}
|
||||
|
||||
func applyMigrations(db *gorm.DB) error {
|
||||
|
||||
@@ -28,6 +28,7 @@ func TestMigrationRegistryContainsOrderedMigrations(t *testing.T) {
|
||||
migrationfiles.ID0014,
|
||||
migrationfiles.ID0015,
|
||||
migrationfiles.ID0016,
|
||||
migrationfiles.ID0017,
|
||||
}
|
||||
if len(migrations) != len(wantIDs) {
|
||||
t.Fatalf("migration registry length = %d, want %d", len(migrations), len(wantIDs))
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const ID0017 = "0017_request_log_operation_index"
|
||||
const requestLogOperationIndex0017 = "idx_request_logs_operation_completed_id"
|
||||
|
||||
type requestLog0017 struct {
|
||||
Operation string `gorm:"column:operation;index:idx_request_logs_operation_completed_id,priority:1"`
|
||||
CompletedAtMS int64 `gorm:"column:completed_at_ms;index:idx_request_logs_operation_completed_id,priority:2,sort:desc"`
|
||||
ID string `gorm:"column:id;index:idx_request_logs_operation_completed_id,priority:3,sort:desc"`
|
||||
}
|
||||
|
||||
func (requestLog0017) TableName() string { return "request_logs" }
|
||||
|
||||
type requestLogOperationIndexColumn0017 struct {
|
||||
Name string
|
||||
Descending bool
|
||||
IsUnique bool
|
||||
IsValid bool
|
||||
}
|
||||
|
||||
// Up0017 为操作筛选增加与游标顺序一致的索引,不改历史日志。
|
||||
func Up0017(db *gorm.DB) error {
|
||||
if err := ValidateRecoverable0017(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if !db.Migrator().HasIndex(&requestLog0017{}, requestLogOperationIndex0017) {
|
||||
if err := db.Migrator().CreateIndex(&requestLog0017{}, requestLogOperationIndex0017); err != nil {
|
||||
return fmt.Errorf("create request log operation index: %w", err)
|
||||
}
|
||||
}
|
||||
return Validate0017(db)
|
||||
}
|
||||
|
||||
func ValidateRecoverable0017(db *gorm.DB) error {
|
||||
if !db.Migrator().HasTable(&requestLog0017{}) {
|
||||
return fmt.Errorf("request log operation index: request_logs table is missing")
|
||||
}
|
||||
if db.Migrator().HasIndex(&requestLog0017{}, requestLogOperationIndex0017) {
|
||||
return Validate0017(db)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func Validate0017(db *gorm.DB) error {
|
||||
columns, err := requestLogOperationIndexColumns0017(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
want := []struct {
|
||||
name string
|
||||
desc bool
|
||||
}{
|
||||
{name: "operation"},
|
||||
{name: "completed_at_ms", desc: true},
|
||||
{name: "id", desc: true},
|
||||
}
|
||||
if len(columns) != len(want) {
|
||||
return fmt.Errorf("request log operation index is missing or has an unexpected definition")
|
||||
}
|
||||
for position, column := range columns {
|
||||
if column.Name != want[position].name || column.Descending != want[position].desc || column.IsUnique || !column.IsValid {
|
||||
return fmt.Errorf("request log operation index has an unexpected definition")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func requestLogOperationIndexColumns0017(db *gorm.DB) ([]requestLogOperationIndexColumn0017, error) {
|
||||
var columns []requestLogOperationIndexColumn0017
|
||||
var query string
|
||||
var arguments []any
|
||||
switch db.Dialector.Name() {
|
||||
case "postgres":
|
||||
// GORM 的 PostgreSQL GetIndexes 未按 indkey 的位置排序,也不返回排序方向。
|
||||
query = `
|
||||
SELECT attribute.attname AS name,
|
||||
(definition.indoption[key.position - 1] & 1) <> 0 AS descending,
|
||||
definition.indisunique AS is_unique, definition.indisvalid AS is_valid
|
||||
FROM pg_index AS definition
|
||||
JOIN pg_class AS table_relation ON table_relation.oid = definition.indrelid
|
||||
JOIN pg_namespace AS namespace ON namespace.oid = table_relation.relnamespace
|
||||
JOIN pg_class AS index_relation ON index_relation.oid = definition.indexrelid
|
||||
CROSS JOIN LATERAL unnest(definition.indkey) WITH ORDINALITY AS key(attnum, position)
|
||||
JOIN pg_attribute AS attribute ON attribute.attrelid = table_relation.oid
|
||||
AND attribute.attnum = key.attnum
|
||||
WHERE namespace.nspname = current_schema() AND table_relation.relname = 'request_logs'
|
||||
AND index_relation.relname = ?
|
||||
ORDER BY key.position`
|
||||
arguments = []any{requestLogOperationIndex0017}
|
||||
case "mysql":
|
||||
query = `
|
||||
SELECT column_name AS name, collation = 'D' AS descending,
|
||||
non_unique = 0 AS is_unique, 1 AS is_valid
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE() AND table_name = 'request_logs' AND index_name = ?
|
||||
ORDER BY seq_in_index`
|
||||
arguments = []any{requestLogOperationIndex0017}
|
||||
case "sqlite":
|
||||
query = `
|
||||
SELECT column_info.name AS name, column_info.desc <> 0 AS descending,
|
||||
index_list."unique" <> 0 AS is_unique, 1 AS is_valid
|
||||
FROM pragma_index_xinfo(?) AS column_info
|
||||
JOIN pragma_index_list('request_logs') AS index_list ON index_list.name = ?
|
||||
WHERE column_info.key = 1
|
||||
ORDER BY column_info.seqno`
|
||||
arguments = []any{requestLogOperationIndex0017, requestLogOperationIndex0017}
|
||||
default:
|
||||
return nil, fmt.Errorf("request log operation index: unsupported database driver %q", db.Dialector.Name())
|
||||
}
|
||||
if err := db.Raw(query, arguments...).Scan(&columns).Error; err != nil {
|
||||
return nil, fmt.Errorf("read request log operation index: %w", err)
|
||||
}
|
||||
return columns, nil
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package migrations_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gpt-load/internal/storage/migrations"
|
||||
)
|
||||
|
||||
func TestRequestLogOperationIndexRejectsMissingTableAndIndex(t *testing.T) {
|
||||
db := openInitialTestDatabase(t)
|
||||
if err := migrations.ValidateRecoverable0017(db); err == nil {
|
||||
t.Fatal("missing request_logs table accepted")
|
||||
}
|
||||
if err := migrations.Up0001(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := migrations.Validate0017(db); err == nil {
|
||||
t.Fatal("missing operation index accepted")
|
||||
}
|
||||
for range 2 {
|
||||
if err := migrations.Up0017(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := migrations.Validate0017(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestOperationIndexMigrationContract(t *testing.T) {
|
||||
testOperationIndexMigration(t, openInternalMigrationTestDatabase)
|
||||
}
|
||||
|
||||
func TestExternalOperationIndexMigrationContract(t *testing.T) {
|
||||
dsn := os.Getenv("GPT_LOAD_DATABASE_TEST_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("GPT_LOAD_DATABASE_TEST_DSN is not set")
|
||||
}
|
||||
testOperationIndexMigration(t, func(t *testing.T) *gorm.DB {
|
||||
return openExternalIncrementalMigrationDatabase(t, dsn)
|
||||
})
|
||||
}
|
||||
|
||||
func testOperationIndexMigration(t *testing.T, open func(*testing.T) *gorm.DB) {
|
||||
t.Helper()
|
||||
for _, scenario := range []string{"fresh", "existing", "interrupted"} {
|
||||
t.Run(scenario, func(t *testing.T) {
|
||||
db := open(t)
|
||||
if scenario != "fresh" {
|
||||
if err := applyMigrationRegistry(db, migrations[:len(migrations)-1]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Table("request_logs").Create(map[string]any{
|
||||
"id": "existing", "completed_at_ms": 1, "access_key_id": 1,
|
||||
"protocol": "openai-responses", "operation": "responses_retrieve",
|
||||
"client_model": "test", "upstream_model": "test", "status": "error",
|
||||
"status_code": 500, "duration_ms": 1, "error_summary": "existing diagnostic",
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if scenario == "interrupted" {
|
||||
registry := append([]migration(nil), migrations...)
|
||||
up := registry[len(registry)-1].Up
|
||||
registry[len(registry)-1].Up = func(tx *gorm.DB) error {
|
||||
if err := up(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("interrupt after operation index DDL")
|
||||
}
|
||||
if err := applyMigrationRegistry(db, registry); err == nil {
|
||||
t.Fatal("expected migration interruption")
|
||||
}
|
||||
}
|
||||
}
|
||||
for range 2 {
|
||||
if err := AutoMigrate(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !db.Migrator().HasIndex("request_logs", "idx_request_logs_operation_completed_id") {
|
||||
t.Fatal("operation cursor index is missing")
|
||||
}
|
||||
if scenario != "fresh" {
|
||||
var operation string
|
||||
if err := db.Table("request_logs").Where("id = ?", "existing").Pluck("operation", &operation).Error; err != nil || operation != "responses_retrieve" {
|
||||
t.Fatalf("existing operation = %q, error = %v", operation, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
t.Run("unexpected index definition", func(t *testing.T) {
|
||||
db := open(t)
|
||||
if err := applyMigrationRegistry(db, migrations[:len(migrations)-1]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Exec("CREATE INDEX idx_request_logs_operation_completed_id ON request_logs (status)").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := AutoMigrate(db); err == nil {
|
||||
t.Fatal("unexpected operation index definition accepted")
|
||||
}
|
||||
})
|
||||
t.Run("unexpected index direction", func(t *testing.T) {
|
||||
db := open(t)
|
||||
if err := applyMigrationRegistry(db, migrations[:len(migrations)-1]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Exec(
|
||||
"CREATE INDEX idx_request_logs_operation_completed_id ON request_logs (operation, completed_at_ms DESC, id ASC)",
|
||||
).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := AutoMigrate(db); err == nil {
|
||||
t.Fatal("unexpected operation index direction accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -3,6 +3,25 @@ import { ApiError, InvalidResponseError } from '@shared/http/errors'
|
||||
import { boolean, integer, list, oneOf, record, text } from './response'
|
||||
|
||||
export const logStatuses = ['success', 'error', 'incomplete', 'canceled'] as const
|
||||
export const logOperations = [
|
||||
'chat_completion',
|
||||
'responses_create',
|
||||
'responses_retrieve',
|
||||
'responses_delete',
|
||||
'responses_cancel',
|
||||
'responses_input_items',
|
||||
'responses_compact',
|
||||
'responses_input_tokens',
|
||||
'count_tokens',
|
||||
'responses_passthrough',
|
||||
'web_search',
|
||||
'images_generate',
|
||||
'images_edit',
|
||||
'embeddings_create',
|
||||
'rerank',
|
||||
'list_models',
|
||||
'probe',
|
||||
] as const
|
||||
export const logFilterNames = [
|
||||
'from_ms',
|
||||
'to_ms',
|
||||
@@ -17,6 +36,7 @@ export const logFilterNames = [
|
||||
'status',
|
||||
'request_id',
|
||||
'protocol',
|
||||
'operation',
|
||||
'stream',
|
||||
'final_status_code',
|
||||
'usage_state',
|
||||
@@ -88,6 +108,7 @@ export interface LogEntry {
|
||||
error_code: string
|
||||
error_summary: string
|
||||
affinity_hit: boolean
|
||||
affinity_kind: string
|
||||
group_id: number | null
|
||||
channel_id: string | null
|
||||
credential_id: number | null
|
||||
@@ -258,6 +279,7 @@ function entry(value: unknown): LogEntry {
|
||||
error_code: text(row.error_code),
|
||||
error_summary: text(row.error_summary),
|
||||
affinity_hit: boolean(row.affinity_hit),
|
||||
affinity_kind: text(row.affinity_kind),
|
||||
group_id: optionalNumber(row.group_id),
|
||||
channel_id: optionalText(row.channel_id),
|
||||
credential_id: optionalNumber(row.credential_id),
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onScopeDispose } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { AppCopyValue, AppOverflowText } from '@modern/components/ui'
|
||||
import { revealCredential } from '@modern/api/credential-actions'
|
||||
import { useApiClient } from '@shared/http/client-context'
|
||||
|
||||
const props = defineProps<{
|
||||
name: string
|
||||
groupId: number | null
|
||||
credentialId: number | null
|
||||
deleted: boolean
|
||||
connectionType?: 'api_key' | 'subscription'
|
||||
}>()
|
||||
const { t } = useI18n()
|
||||
const client = useApiClient()
|
||||
const controller = new AbortController()
|
||||
onScopeDispose(() => controller.abort())
|
||||
const display = computed(
|
||||
() =>
|
||||
props.name ||
|
||||
(props.credentialId ? t(props.deleted ? 'logs.deleted' : 'logs.unavailableCredential') : '—'),
|
||||
)
|
||||
const canCopy = computed(() =>
|
||||
Boolean(
|
||||
!props.deleted &&
|
||||
props.name &&
|
||||
(props.connectionType === 'subscription' ||
|
||||
(props.connectionType === 'api_key' && props.groupId && props.credentialId)),
|
||||
),
|
||||
)
|
||||
function resolveSecret(): Promise<string> {
|
||||
if (!props.groupId || !props.credentialId) throw new Error('CREDENTIAL_NOT_AVAILABLE')
|
||||
return revealCredential(client, props.groupId, props.credentialId, controller.signal)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppCopyValue
|
||||
v-if="canCopy"
|
||||
:value="name"
|
||||
:label="t('logs.copyCredential')"
|
||||
:resolve-value="connectionType === 'api_key' ? resolveSecret : undefined"
|
||||
/>
|
||||
<AppOverflowText v-else :text="display" :class="{ 'modern-log-credential-deleted': deleted }" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.modern-log-credential-deleted {
|
||||
color: var(--modern-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -31,6 +31,7 @@ import { createRedactedLogExport } from './log-redacted-export'
|
||||
import LogPricingReceipt from './LogPricingReceipt.vue'
|
||||
import LogValue from './LogValue.vue'
|
||||
import LogModelWarning from './LogModelWarning.vue'
|
||||
import LogCredentialValue from './LogCredentialValue.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
id: string
|
||||
@@ -302,17 +303,18 @@ function resolveRedactedLog(): Promise<string> {
|
||||
><span>{{ logDuration(attempt.duration_ms, locale) }}</span>
|
||||
</div>
|
||||
<div class="modern-log-attempt-route">
|
||||
<span :class="{ 'is-deleted': attempt.credential_deleted }">{{
|
||||
attempt.credential_name ||
|
||||
(attempt.credential_id
|
||||
? t(
|
||||
attempt.credential_deleted
|
||||
? 'logs.deleted'
|
||||
: 'logs.unavailableCredential',
|
||||
)
|
||||
: '—')
|
||||
}}</span
|
||||
><AppOverflowText :text="attempt.upstream_model ?? '—'" /><AppBadge
|
||||
<LogCredentialValue
|
||||
:name="attempt.credential_name"
|
||||
:group-id="attempt.group_id"
|
||||
:credential-id="attempt.credential_id"
|
||||
:deleted="attempt.credential_deleted"
|
||||
:connection-type="
|
||||
(attempt.channel_id
|
||||
? channels?.get(attempt.channel_id)?.connectionType
|
||||
: undefined) ?? groups?.get(attempt.group_id)?.connectionType
|
||||
"
|
||||
/>
|
||||
<AppOverflowText :text="attempt.upstream_model ?? '—'" /><AppBadge
|
||||
v-if="attempt.will_retry"
|
||||
tone="warning"
|
||||
variant="soft"
|
||||
|
||||
@@ -397,6 +397,14 @@ const loadCredentials = computed(() => {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<AppSearchSelect
|
||||
v-else-if="field.key === 'operation'"
|
||||
:model-value="draft.operation ?? ''"
|
||||
:options="options(field)"
|
||||
:label="t('logs.filters.operation')"
|
||||
size="xs"
|
||||
@update:model-value="update('operation', $event)"
|
||||
/>
|
||||
<AppSelect
|
||||
v-else-if="field.kind === 'select'"
|
||||
:model-value="draft[field.key] ?? ''"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { TriangleAlert } from '@lucide/vue'
|
||||
import { CircleHelp, TriangleAlert } from '@lucide/vue'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { LogEntry } from '@modern/api/logs'
|
||||
@@ -9,29 +9,41 @@ import { logModelMismatch } from './log-display'
|
||||
const props = defineProps<{ row: LogEntry; detail?: boolean }>()
|
||||
const { t } = useI18n()
|
||||
const mismatch = computed(() => logModelMismatch(props.row))
|
||||
const unknown = computed(() => props.row.model_consistency === 'unknown')
|
||||
const title = computed(() =>
|
||||
t(mismatch.value ? 'logs.returnedModelMismatch' : 'logs.returnedModelUnknown'),
|
||||
)
|
||||
const description = computed(() =>
|
||||
t('logs.returnedModelMismatchHint', {
|
||||
requested: props.row.upstream_model,
|
||||
returned: props.row.upstream_reported_model,
|
||||
}),
|
||||
mismatch.value
|
||||
? t('logs.returnedModelMismatchHint', {
|
||||
requested: props.row.upstream_model || '—',
|
||||
returned: props.row.upstream_reported_model || t('logs.modelNotObserved'),
|
||||
})
|
||||
: t('logs.returnedModelUnknownHint', { requested: props.row.upstream_model || '—' }),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="detail && mismatch" class="modern-log-model-warning is-detail" role="note">
|
||||
<AppIcon :icon="TriangleAlert" size="sm" />
|
||||
<div
|
||||
v-if="detail && (mismatch || unknown)"
|
||||
class="modern-log-model-warning is-detail"
|
||||
:class="{ 'is-unknown': unknown }"
|
||||
:role="mismatch ? 'alert' : 'note'"
|
||||
>
|
||||
<AppIcon :icon="mismatch ? TriangleAlert : CircleHelp" size="sm" />
|
||||
<div>
|
||||
<strong>{{ t('logs.returnedModelMismatch') }}</strong>
|
||||
<strong>{{ title }}</strong>
|
||||
<p>{{ description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<AppTooltip v-else-if="mismatch" :label="t('logs.returnedModelMismatch') + '\n' + description">
|
||||
<AppTooltip v-else-if="mismatch || unknown" :label="title + '\n' + description">
|
||||
<span
|
||||
class="modern-log-model-warning"
|
||||
:class="{ 'is-unknown': unknown }"
|
||||
tabindex="0"
|
||||
:aria-label="t('logs.returnedModelMismatch') + '\n' + description"
|
||||
:aria-label="title + '\n' + description"
|
||||
>
|
||||
<AppIcon :icon="TriangleAlert" size="inherit" />
|
||||
<AppIcon :icon="mismatch ? TriangleAlert : CircleHelp" size="inherit" />
|
||||
</span>
|
||||
</AppTooltip>
|
||||
</template>
|
||||
@@ -45,9 +57,15 @@ const description = computed(() =>
|
||||
color: var(--modern-danger);
|
||||
}
|
||||
.modern-log-model-warning:focus-visible {
|
||||
outline: var(--modern-focus-width) solid var(--modern-danger);
|
||||
outline: var(--modern-focus-width) solid currentColor;
|
||||
outline-offset: var(--modern-focus-offset);
|
||||
}
|
||||
.modern-log-model-warning.is-unknown {
|
||||
color: var(--modern-info);
|
||||
}
|
||||
.modern-log-model-warning.is-detail.is-unknown {
|
||||
background: var(--modern-info-soft);
|
||||
}
|
||||
.modern-log-model-warning.is-detail {
|
||||
width: 100%;
|
||||
align-items: flex-start;
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
AppTooltip,
|
||||
} from '@modern/components/ui'
|
||||
import type { LogColumnId } from './log-columns'
|
||||
import { logTime } from './log-display'
|
||||
import { logOutputRate, logTime } from './log-display'
|
||||
import LogValue from './LogValue.vue'
|
||||
import LogModelWarning from './LogModelWarning.vue'
|
||||
import LogFilterLink from './LogFilterLink.vue'
|
||||
@@ -128,6 +128,8 @@ function fieldFilter(field: LogColumnId): LogQuery | undefined {
|
||||
return row.status_code ? { final_status_code: String(row.status_code) } : undefined
|
||||
case 'stream':
|
||||
return { stream: String(row.stream) }
|
||||
case 'operation':
|
||||
return row.operation ? { operation: row.operation } : undefined
|
||||
case 'usage_state':
|
||||
return { usage_state: row.usage_state }
|
||||
case 'cost_state':
|
||||
@@ -171,6 +173,8 @@ function fieldFilterValue(field: LogColumnId): string {
|
||||
return t(row.stream ? 'logs.yes' : 'logs.no')
|
||||
case 'error_code':
|
||||
return row.error_code
|
||||
case 'operation':
|
||||
return row.operation ? t('logs.values.' + row.operation) : '—'
|
||||
default:
|
||||
return field
|
||||
}
|
||||
@@ -288,6 +292,24 @@ function fieldFilterValue(field: LogColumnId): string {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="fields.includes('duration_ms')" class="modern-log-cell-stack is-paired">
|
||||
<div class="modern-log-cell-value">
|
||||
<LogValue :row="row" column="duration_ms" table />
|
||||
<template v-if="fields.includes('first_response_ms')">
|
||||
<span aria-hidden="true">/</span>
|
||||
<LogValue :row="row" column="first_response_ms" table />
|
||||
</template>
|
||||
</div>
|
||||
<AppTooltip :label="t('logs.outputRate')">
|
||||
<div
|
||||
class="modern-log-cell-value modern-log-speed"
|
||||
tabindex="0"
|
||||
:aria-label="t('logs.outputRate') + ': ' + logOutputRate(row, locale)"
|
||||
>
|
||||
<AppOverflowText :text="logOutputRate(row, locale)" />
|
||||
</div>
|
||||
</AppTooltip>
|
||||
</div>
|
||||
<div v-else class="modern-log-cell-stack" :class="{ 'is-paired': paired }">
|
||||
<template v-for="(field, index) in fields" :key="field">
|
||||
<div
|
||||
@@ -434,6 +456,11 @@ function fieldFilterValue(field: LogColumnId): string {
|
||||
.modern-log-cell-value > :last-child {
|
||||
min-width: 0;
|
||||
}
|
||||
.modern-log-speed {
|
||||
font-family: var(--modern-font-mono);
|
||||
font-size: var(--modern-font-size-small);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.modern-log-cell-value.is-model {
|
||||
font-family: var(--modern-font-mono);
|
||||
font-size: var(--modern-font-size-small);
|
||||
|
||||
@@ -22,9 +22,11 @@ import {
|
||||
logHasUsage,
|
||||
logMoney,
|
||||
logNumber,
|
||||
logOutputRate,
|
||||
logStatusTone,
|
||||
logTime,
|
||||
} from './log-display'
|
||||
import LogCredentialValue from './LogCredentialValue.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
row: LogEntry
|
||||
@@ -44,6 +46,11 @@ const channel = computed(() =>
|
||||
function valueName(value: string | null | undefined): string {
|
||||
return !value ? '—' : te('logs.values.' + value) ? t('logs.values.' + value) : value
|
||||
}
|
||||
const affinityReason = computed(() => {
|
||||
if (!props.row.affinity_hit) return ''
|
||||
const key = 'logs.affinityKinds.' + props.row.affinity_kind
|
||||
return te(key) ? t(key) : t('logs.affinityKinds.other')
|
||||
})
|
||||
const tokenValue = computed(() => {
|
||||
const row = props.row
|
||||
if (props.column === 'cache_write_tokens') return logCacheWrites(row)
|
||||
@@ -176,6 +183,14 @@ const hint = computed(() => {
|
||||
:value="row.request_id"
|
||||
:label="t('logs.copyRequest')"
|
||||
/>
|
||||
<LogCredentialValue
|
||||
v-else-if="column === 'credential_name' && !table"
|
||||
:name="row.credential_name"
|
||||
:group-id="row.group_id"
|
||||
:credential-id="row.credential_id"
|
||||
:deleted="row.credential_deleted"
|
||||
:connection-type="channel?.connectionType ?? group?.connectionType"
|
||||
/>
|
||||
<div v-else-if="column === 'group' && group" class="modern-log-channel">
|
||||
<AppChannelIcon
|
||||
v-if="!hideIcon && table"
|
||||
@@ -200,6 +215,22 @@ const hint = computed(() => {
|
||||
v-else-if="column === 'protocol' || column === 'upstream_protocol'"
|
||||
:protocol="row[column]"
|
||||
/>
|
||||
<AppTooltip v-else-if="column === 'affinity_hit' && row.affinity_hit" :label="affinityReason">
|
||||
<span
|
||||
tabindex="0"
|
||||
:aria-label="display + ': ' + affinityReason"
|
||||
:class="{ 'modern-log-boolean': table, 'is-true': row.affinity_hit }"
|
||||
>{{ display }}</span
|
||||
>
|
||||
</AppTooltip>
|
||||
<span v-else-if="column === 'duration_ms' && !table" class="modern-log-value-stack">
|
||||
<span>{{ display }}</span>
|
||||
<AppTooltip :label="t('logs.outputRate')">
|
||||
<span tabindex="0" :aria-label="t('logs.outputRate') + ': ' + logOutputRate(row, locale)">{{
|
||||
logOutputRate(row, locale)
|
||||
}}</span>
|
||||
</AppTooltip>
|
||||
</span>
|
||||
<span
|
||||
v-else-if="table && (column === 'stream' || column === 'affinity_hit')"
|
||||
class="modern-log-boolean"
|
||||
@@ -234,6 +265,12 @@ const hint = computed(() => {
|
||||
.modern-log-deleted {
|
||||
color: var(--modern-muted);
|
||||
}
|
||||
.modern-log-value-stack {
|
||||
display: inline-grid;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
gap: var(--modern-space-0-5);
|
||||
}
|
||||
.modern-log-channel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -198,7 +198,7 @@ export function useLogColumns(admin: boolean) {
|
||||
['route_mode', 'affinity_hit', 62],
|
||||
['status', 'status_code', 76],
|
||||
['error_code', 'error_summary', 200],
|
||||
['duration_ms', 'first_response_ms', 72],
|
||||
['duration_ms', 'first_response_ms', 132],
|
||||
['input_tokens', 'output_tokens', 68],
|
||||
['cache_read_tokens', 'cache_hit_rate', 68],
|
||||
['cache_write_tokens', 'total_tokens', 68],
|
||||
|
||||
@@ -9,11 +9,7 @@ export const logStatusTone: Record<LogEntry['status'], SemanticTone> = {
|
||||
canceled: 'neutral',
|
||||
}
|
||||
export function logModelMismatch(row: LogEntry): boolean {
|
||||
return (
|
||||
row.model_consistency === 'mismatch' &&
|
||||
Boolean(row.upstream_model) &&
|
||||
Boolean(row.upstream_reported_model)
|
||||
)
|
||||
return row.model_consistency === 'mismatch'
|
||||
}
|
||||
export function logCanMergeError(row: LogEntry): boolean {
|
||||
// 中断或失败仍可能产生实际消耗;真实的零用量/零费用也不能当成缺失。
|
||||
@@ -65,6 +61,21 @@ export function logDuration(ms: number | null, locale: string): string {
|
||||
}).format(ms < 1000 ? ms : ms / 1000) + (ms < 1000 ? ' ms' : ' s')
|
||||
)
|
||||
}
|
||||
export function logOutputRate(row: LogEntry, locale: string): string {
|
||||
if (
|
||||
!row.stream ||
|
||||
!logHasUsage(row) ||
|
||||
row.first_response_ms === null ||
|
||||
row.duration_ms <= row.first_response_ms
|
||||
)
|
||||
return '—'
|
||||
const output = Number(row.output_tokens)
|
||||
if (!Number.isSafeInteger(output) || output <= 0) return '—'
|
||||
const rate = output / ((row.duration_ms - row.first_response_ms) / 1000)
|
||||
return Number.isFinite(rate)
|
||||
? numberFormatter(locale, { maximumFractionDigits: 1 }).format(rate) + ' t/s'
|
||||
: '—'
|
||||
}
|
||||
export function logTime(ms: number, locale: string, full = false): string {
|
||||
return dateFormatter(locale, {
|
||||
...(full ? { year: 'numeric' as const } : {}),
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
internalLogFilters,
|
||||
logCursorPattern,
|
||||
logFilterNames,
|
||||
logOperations,
|
||||
logRequestPattern,
|
||||
logStatuses,
|
||||
type LogFilterName,
|
||||
@@ -29,6 +30,7 @@ export interface LogFilterDefinition {
|
||||
export const logFilterOptions: Partial<Record<LogFilterName, readonly string[]>> = {
|
||||
status: logStatuses,
|
||||
protocol: accessProtocols,
|
||||
operation: logOperations,
|
||||
stream: ['true', 'false'],
|
||||
cache_present: ['true', 'false'],
|
||||
usage_state: ['complete', 'partial', 'missing', 'not_applicable'],
|
||||
@@ -52,6 +54,7 @@ export const logFilterOptions: Partial<Record<LogFilterName, readonly string[]>>
|
||||
export const advancedLogFilters: readonly LogFilterDefinition[] = [
|
||||
{ key: 'request_id', section: 'request', kind: 'text' },
|
||||
{ key: 'protocol', section: 'request', kind: 'select', values: accessProtocols },
|
||||
{ key: 'operation', section: 'request', kind: 'select', values: logOperations },
|
||||
{ key: 'stream', section: 'request', kind: 'select', values: logFilterOptions.stream },
|
||||
{
|
||||
key: 'retry_state',
|
||||
@@ -62,6 +65,7 @@ export const advancedLogFilters: readonly LogFilterDefinition[] = [
|
||||
},
|
||||
{ key: 'retry_count_min', section: 'routing', kind: 'number', admin: true },
|
||||
{ key: 'retry_count_max', section: 'routing', kind: 'number', admin: true },
|
||||
{ key: 'upstream_model', section: 'routing', kind: 'text', admin: true },
|
||||
{ key: 'final_status_code', section: 'result', kind: 'number' },
|
||||
{
|
||||
key: 'model_consistency',
|
||||
|
||||
@@ -9,6 +9,16 @@ export const zhCN = {
|
||||
modelUpstream: '上游模型',
|
||||
},
|
||||
returnedModelMismatch: '返回模型不一致',
|
||||
returnedModelUnknown: '返回模型未知',
|
||||
returnedModelUnknownHint: '请求上游模型:{requested}\n上游未返回模型标识,无法确认模型是否一致。',
|
||||
modelNotObserved: '未观测到',
|
||||
outputRate: '输出速度',
|
||||
affinityKinds: {
|
||||
prompt_prefix: '提示词前缀',
|
||||
prompt_cache_key: '缓存键',
|
||||
response_continuity: '响应连续性',
|
||||
other: '亲和命中',
|
||||
},
|
||||
standaloneSearchHint: 'Codex 独立搜索请求',
|
||||
returnedModelMismatchHint: '请求上游模型:{requested}\n上游返回模型:{returned}',
|
||||
columnGroups: {
|
||||
@@ -58,6 +68,7 @@ export const zhCN = {
|
||||
selectGroupFirst: '先选择分组',
|
||||
copyRequest: '复制请求 ID',
|
||||
copyError: '复制错误代码',
|
||||
copyCredential: '复制凭据',
|
||||
copyRedactedLog: '复制脱敏日志',
|
||||
redactedCopySuccess: '已复制脱敏副本,账号、密钥等标识已匿名化,常见敏感内容已处理。',
|
||||
redactedCopyFailed: '脱敏日志复制失败,请重试。',
|
||||
@@ -147,6 +158,7 @@ export const zhCN = {
|
||||
error_summary: '错误详情',
|
||||
},
|
||||
filters: {
|
||||
operation: '操作',
|
||||
group_id: '分组',
|
||||
channel_id: '渠道',
|
||||
credential_id: '账号',
|
||||
@@ -264,6 +276,7 @@ export const zhCN = {
|
||||
responses_input_tokens: '输入 Token 统计',
|
||||
count_tokens: 'Token 统计',
|
||||
responses_passthrough: '响应透传',
|
||||
web_search: '独立搜索',
|
||||
images_generate: '图片生成',
|
||||
images_edit: '图片编辑',
|
||||
embeddings_create: '向量生成',
|
||||
@@ -291,6 +304,17 @@ export const enUS: typeof zhCN = {
|
||||
modelUpstream: 'Upstream model',
|
||||
},
|
||||
returnedModelMismatch: 'Returned model mismatch',
|
||||
returnedModelUnknown: 'Returned model unknown',
|
||||
returnedModelUnknownHint:
|
||||
'Requested upstream model: {requested}\nThe upstream did not report a model identifier, so consistency cannot be verified.',
|
||||
modelNotObserved: 'Not observed',
|
||||
outputRate: 'Output speed',
|
||||
affinityKinds: {
|
||||
prompt_prefix: 'Prompt prefix',
|
||||
prompt_cache_key: 'Cache key',
|
||||
response_continuity: 'Response continuity',
|
||||
other: 'Affinity match',
|
||||
},
|
||||
standaloneSearchHint: 'Codex standalone search request',
|
||||
returnedModelMismatchHint:
|
||||
'Requested upstream model: {requested}\nReported upstream model: {returned}',
|
||||
@@ -342,6 +366,7 @@ export const enUS: typeof zhCN = {
|
||||
selectGroupFirst: 'Select a group first',
|
||||
copyRequest: 'Copy request ID',
|
||||
copyError: 'Copy error code',
|
||||
copyCredential: 'Copy credential',
|
||||
copyRedactedLog: 'Copy sanitized log',
|
||||
redactedCopySuccess:
|
||||
'Sanitized log copied. Account and key identities are anonymized; common sensitive content has been redacted.',
|
||||
@@ -433,6 +458,7 @@ export const enUS: typeof zhCN = {
|
||||
},
|
||||
filters: {
|
||||
group_id: 'Group',
|
||||
operation: 'Operation',
|
||||
channel_id: 'Channel',
|
||||
credential_id: 'Account',
|
||||
access_key_id: 'Access key',
|
||||
@@ -549,6 +575,7 @@ export const enUS: typeof zhCN = {
|
||||
responses_input_tokens: 'Input token count',
|
||||
count_tokens: 'Count tokens',
|
||||
responses_passthrough: 'Response passthrough',
|
||||
web_search: 'Standalone search',
|
||||
images_generate: 'Generate image',
|
||||
images_edit: 'Edit image',
|
||||
embeddings_create: 'Create embedding',
|
||||
@@ -576,6 +603,17 @@ export const jaJP: typeof zhCN = {
|
||||
modelUpstream: '上流モデル',
|
||||
},
|
||||
returnedModelMismatch: '返却モデルが不一致',
|
||||
returnedModelUnknown: '返却モデルが不明',
|
||||
returnedModelUnknownHint:
|
||||
'要求した上流モデル:{requested}\n上流からモデル識別子が返されなかったため、一致を確認できません。',
|
||||
modelNotObserved: '未観測',
|
||||
outputRate: '出力速度',
|
||||
affinityKinds: {
|
||||
prompt_prefix: 'プロンプト接頭辞',
|
||||
prompt_cache_key: 'キャッシュキー',
|
||||
response_continuity: '応答の連続性',
|
||||
other: 'アフィニティ一致',
|
||||
},
|
||||
standaloneSearchHint: 'Codex の独立検索リクエスト',
|
||||
returnedModelMismatchHint: '要求した上流モデル:{requested}\n上流が返却したモデル:{returned}',
|
||||
columnGroups: {
|
||||
@@ -625,6 +663,7 @@ export const jaJP: typeof zhCN = {
|
||||
selectGroupFirst: '先にグループを選択',
|
||||
copyRequest: 'リクエスト ID をコピー',
|
||||
copyError: 'エラーコードをコピー',
|
||||
copyCredential: '認証情報をコピー',
|
||||
copyRedactedLog: '匿名化ログをコピー',
|
||||
redactedCopySuccess:
|
||||
'匿名化ログをコピーしました。アカウントとキーの識別情報を匿名化し、一般的な機密情報を処理しました。',
|
||||
@@ -716,6 +755,7 @@ export const jaJP: typeof zhCN = {
|
||||
},
|
||||
filters: {
|
||||
group_id: 'グループ',
|
||||
operation: '操作',
|
||||
channel_id: 'チャネル',
|
||||
credential_id: 'アカウント',
|
||||
access_key_id: 'アクセスキー',
|
||||
@@ -832,6 +872,7 @@ export const jaJP: typeof zhCN = {
|
||||
responses_input_tokens: '入力 Token 数',
|
||||
count_tokens: 'Token 計数',
|
||||
responses_passthrough: '応答パススルー',
|
||||
web_search: '独立検索',
|
||||
images_generate: '画像生成',
|
||||
images_edit: '画像編集',
|
||||
embeddings_create: '埋め込み生成',
|
||||
|
||||
Reference in new issue
Block a user