From a4ea8059b7023ebf774dc1a9e78604712e0ff35f Mon Sep 17 00:00:00 2001 From: wucm667 Date: Tue, 29 Sep 2026 16:42:02 +0800 Subject: [PATCH 01/88] fix(openai): recover rejected encrypted reasoning signatures --- .../service/openai_gateway_forward.go | 7 +- .../openai_gateway_service_hotpath_test.go | 64 +++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index 10c525550a8f..11468a00ffaa 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -1113,7 +1113,12 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco } respBody = s.redactAgentIdentitySensitiveBody(ctx, account, respBody) resp.Body = io.NopCloser(bytes.NewReader(respBody)) - if !httpInvalidEncryptedContentRetryTried && resp.StatusCode == http.StatusBadRequest && upstreamCode == "invalid_encrypted_content" { + invalidEncryptedContentError := upstreamCode == "invalid_encrypted_content" || + (upstreamCode == "thinking_signature_invalid" && + strings.Contains(upstreamMsg, "The encrypted content") && + strings.Contains(upstreamMsg, "could not be verified") && + strings.Contains(upstreamMsg, "could not be decrypted or parsed")) + if !httpInvalidEncryptedContentRetryTried && resp.StatusCode == http.StatusBadRequest && invalidEncryptedContentError { decoded, decodeErr := ensureReqBody() if decodeErr != nil { return nil, decodeErr diff --git a/backend/internal/service/openai_gateway_service_hotpath_test.go b/backend/internal/service/openai_gateway_service_hotpath_test.go index 5142b7934dd0..65ba44f2dd61 100644 --- a/backend/internal/service/openai_gateway_service_hotpath_test.go +++ b/backend/internal/service/openai_gateway_service_hotpath_test.go @@ -637,6 +637,70 @@ func TestOpenAIGatewayService_Forward_HTTPRetryRecoveryDropsCompaction(t *testin require.False(t, gjson.GetBytes(upstream.bodies[1], "input.1").Exists()) } +func TestOpenAIGatewayService_Forward_HTTPThinkingSignatureRecoveryAndLineage(t *testing.T) { + gin.SetMode(gin.TestMode) + response := func(status int, body string) *http.Response { + return &http.Response{StatusCode: status, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(body))} + } + upstream := &httpUpstreamRecorder{responses: []*http.Response{ + response(http.StatusBadRequest, `{"error":{"code":"thinking_signature_invalid","message":"The encrypted content abc could not be verified. Reason: Encrypted content could not be decrypted or parsed."}}`), + response(http.StatusOK, `{"id":"resp_recovered","usage":{"input_tokens":1,"output_tokens":2}}`), + response(http.StatusOK, `{"id":"resp_next","usage":{"input_tokens":1,"output_tokens":2}}`), + }} + cfg := &config.Config{} + cfg.Security.URLAllowlist.Enabled = false + svc := &OpenAIGatewayService{cfg: cfg, httpUpstream: upstream} + account := &Account{ID: 10, Name: "openai-apikey", Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Concurrency: 1, Credentials: map[string]any{"api_key": "sk-test", "base_url": "https://example.com"}, + Extra: map[string]any{"use_responses_api": true}} + newContext := func() *gin.Context { + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", nil) + SetOpenAIClientTransport(c, OpenAIClientTransportHTTP) + return c + } + input := `{"model":"gpt-5","stream":false,"prompt_cache_key":"issue-7700","tools":[{"type":"function","name":"lookup","parameters":{"type":"object"}}],"input":[{"type":"reasoning","encrypted_content":"stale","summary":[{"type":"summary_text","text":"keep"}]},{"type":"message","role":"user","content":[{"type":"input_text","text":"hello"}]},{"type":"function_call_output","call_id":"call_1","output":"result"}]}` + first, err := svc.Forward(context.Background(), newContext(), account, []byte(input)) + require.NoError(t, err) + require.NotNil(t, first) + require.Len(t, upstream.bodies, 2) + require.Equal(t, "stale", gjson.GetBytes(upstream.bodies[0], "input.0.encrypted_content").String()) + require.False(t, gjson.GetBytes(upstream.bodies[1], "input.0.encrypted_content").Exists()) + require.Equal(t, "keep", gjson.GetBytes(upstream.bodies[1], "input.0.summary.0.text").String()) + require.Equal(t, "hello", gjson.GetBytes(upstream.bodies[1], "input.1.content.0.text").String()) + require.Equal(t, "result", gjson.GetBytes(upstream.bodies[1], "input.2.output").String()) + require.Equal(t, "lookup", gjson.GetBytes(upstream.bodies[1], "tools.0.name").String()) + + second, err := svc.Forward(context.Background(), newContext(), account, []byte(input)) + require.NoError(t, err) + require.NotNil(t, second) + require.Len(t, upstream.bodies, 3, "later turn should be stripped before its first upstream request") + require.False(t, gjson.GetBytes(upstream.bodies[2], "input.0.encrypted_content").Exists()) + require.Equal(t, "result", gjson.GetBytes(upstream.bodies[2], "input.2.output").String()) +} + +func TestOpenAIGatewayService_Forward_HTTPThinkingSignatureDoesNotRetryUnrelatedErrors(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, tc := range []struct{ name, code, message, input string }{ + {"no encrypted reasoning", "thinking_signature_invalid", "The encrypted content could not be verified. Reason: Encrypted content could not be decrypted or parsed.", `[{"type":"message","role":"user","content":"hello"}]`}, + {"ordinary 400", "invalid_request_error", "bad request", `[{"type":"reasoning","encrypted_content":"stale"},{"type":"message","content":"hello"}]`}, + {"unrelated signature error", "thinking_signature_invalid", "Invalid signature", `[{"type":"reasoning","encrypted_content":"stale"},{"type":"message","content":"hello"}]`}, + } { + t.Run(tc.name, func(t *testing.T) { + upstream := &httpUpstreamRecorder{resp: &http.Response{StatusCode: http.StatusBadRequest, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(`{"error":{"code":"` + tc.code + `","message":"` + tc.message + `"}}`))}} + cfg := &config.Config{} + cfg.Security.URLAllowlist.Enabled = false + svc := &OpenAIGatewayService{cfg: cfg, httpUpstream: upstream} + account := &Account{ID: 10, Name: "openai-apikey", Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Concurrency: 1, Credentials: map[string]any{"api_key": "sk-test", "base_url": "https://example.com"}, Extra: map[string]any{"use_responses_api": true}} + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", nil) + SetOpenAIClientTransport(c, OpenAIClientTransportHTTP) + _, _ = svc.Forward(context.Background(), c, account, []byte(`{"model":"gpt-5","stream":false,"input":`+tc.input+`}`)) + require.Len(t, upstream.bodies, 1) + }) + } +} + func TestOpenAIGatewayService_Forward_CodexSparkRejectsEscapedInputImage(t *testing.T) { gin.SetMode(gin.TestMode) upstream := &httpUpstreamRecorder{ From 5ace0699b2e8550c10bcfb5b07342d9af4be7380 Mon Sep 17 00:00:00 2001 From: "chihao.ou" Date: Wed, 30 Sep 2026 02:17:23 +0800 Subject: [PATCH 02/88] fix(pricing): preserve exponents when trimming scaled prices --- .../__tests__/pricing.formatScaled.spec.ts | 25 +++++++++++++++++++ frontend/src/utils/pricing.ts | 2 +- 2 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 frontend/src/utils/__tests__/pricing.formatScaled.spec.ts diff --git a/frontend/src/utils/__tests__/pricing.formatScaled.spec.ts b/frontend/src/utils/__tests__/pricing.formatScaled.spec.ts new file mode 100644 index 000000000000..6885061bee1a --- /dev/null +++ b/frontend/src/utils/__tests__/pricing.formatScaled.spec.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest' +import { formatScaled } from '../pricing' + +describe('formatScaled', () => { + it.each([ + [1e-10, 1, '$1e-10'], + [1.25e-10, 1, '$1.25e-10'], + [1e-20, 1, '$1e-20'], + [1e-16, 1_000_000, '$1e-10'], + [1e10, 1, '$10000000000'], + ])('preserves the magnitude of %s scaled by %s', (value, scale, expected) => { + expect(formatScaled(value, scale)).toBe(expected) + }) + + it.each([ + [0.000003, 1_000_000, 0, '$3'], + [0.000003, 1_000_000, 2, '$3.00'], + [1.25e-8, 1_000_000, 2, '$0.0125'], + [5e-8, 1_000_000, 0, '$0.05'], + [0, 1, 2, '$0.00'], + [null, 1_000_000, 0, '-'], + ])('retains ordinary pricing and padding for %s', (value, scale, digits, expected) => { + expect(formatScaled(value, scale, digits)).toBe(expected) + }) +}) diff --git a/frontend/src/utils/pricing.ts b/frontend/src/utils/pricing.ts index b2fbd2e65d4b..feef800041ac 100644 --- a/frontend/src/utils/pricing.ts +++ b/frontend/src/utils/pricing.ts @@ -12,7 +12,7 @@ */ export function formatScaled(value: number | null, scale: number, minFractionDigits = 0): string { if (value == null) return '-' - let s = (value * scale).toPrecision(10).replace(/\.?0+$/, '') + let s = Number((value * scale).toPrecision(10)).toString() if (minFractionDigits > 0 && !s.includes('e')) { const dot = s.indexOf('.') const digits = dot === -1 ? 0 : s.length - dot - 1 From e180a939ac7eb0210d941a52f4bef12140c9e943 Mon Sep 17 00:00:00 2001 From: "chihao.ou" Date: Wed, 30 Sep 2026 02:20:13 +0800 Subject: [PATCH 03/88] fix(users): show normalized errors when bulk updating limits --- .../admin/user/BulkEditUserModal.vue | 5 ++--- .../user/__tests__/BulkEditUserModal.spec.ts | 21 +++++++++++++++++++ 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/admin/user/BulkEditUserModal.vue b/frontend/src/components/admin/user/BulkEditUserModal.vue index d511dff2f605..d38399a9c0c0 100644 --- a/frontend/src/components/admin/user/BulkEditUserModal.vue +++ b/frontend/src/components/admin/user/BulkEditUserModal.vue @@ -93,6 +93,7 @@ import { computed, ref, watch } from 'vue' import { useI18n } from 'vue-i18n' import { adminAPI } from '@/api/admin' +import { extractApiErrorMessage } from '@/utils/apiError' import type { BatchUpdateUserLimitsRequest } from '@/api/admin/users' import { useAppStore } from '@/stores/app' import BaseDialog from '@/components/common/BaseDialog.vue' @@ -203,9 +204,7 @@ const handleSubmit = async () => { emit('close') } catch (error: any) { appStore.showError( - error.response?.data?.message - || error.response?.data?.detail - || t('admin.users.bulkLimits.failed') + extractApiErrorMessage(error, t('admin.users.bulkLimits.failed')) ) } finally { submitting.value = false diff --git a/frontend/src/components/admin/user/__tests__/BulkEditUserModal.spec.ts b/frontend/src/components/admin/user/__tests__/BulkEditUserModal.spec.ts index 1b0277e16dda..6f6bf9c573f4 100644 --- a/frontend/src/components/admin/user/__tests__/BulkEditUserModal.spec.ts +++ b/frontend/src/components/admin/user/__tests__/BulkEditUserModal.spec.ts @@ -59,6 +59,27 @@ describe('BulkEditUserModal', () => { vi.restoreAllMocks() }) + it.each([ + [{ message: 'Selected users are no longer available' }, 'Selected users are no longer available'], + [{ response: { data: { message: 'Legacy message' } } }, 'Legacy message'], + [{ response: { data: { detail: 'Legacy detail' } } }, 'Legacy detail'], + [{}, 'admin.users.bulkLimits.failed'], + ])('shows API error details and allows retry after failure', async (error, expected) => { + vi.spyOn(window, 'confirm').mockReturnValue(true) + batchUpdateLimits.mockRejectedValueOnce(error) + const wrapper = mountModal() + await wrapper.get('[data-test="enable-concurrency"]').trigger('click') + await wrapper.get('[data-test="concurrency-input"]').setValue('5') + await wrapper.get('form').trigger('submit') + await flushPromises() + + expect(showError).toHaveBeenCalledWith(expected) + expect(wrapper.emitted('close')).toBeUndefined() + expect(wrapper.emitted('success')).toBeUndefined() + expect(wrapper.get('[data-test="submit"]').attributes('disabled')).toBeUndefined() + wrapper.unmount() + }) + it('disables submission until at least one enabled field has a value', async () => { const wrapper = mountModal() From 636faa695ba3bf205909b40d2a9498ecd947675c Mon Sep 17 00:00:00 2001 From: "chihao.ou" Date: Wed, 30 Sep 2026 02:20:42 +0800 Subject: [PATCH 04/88] fix(profile): ignore obsolete notification email code responses --- .../user/profile/ProfileBalanceNotifyCard.vue | 4 +++ .../ProfileBalanceNotifyCard.spec.ts | 33 +++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/frontend/src/components/user/profile/ProfileBalanceNotifyCard.vue b/frontend/src/components/user/profile/ProfileBalanceNotifyCard.vue index 988de67509cf..e2f84e996f07 100644 --- a/frontend/src/components/user/profile/ProfileBalanceNotifyCard.vue +++ b/frontend/src/components/user/profile/ProfileBalanceNotifyCard.vue @@ -203,6 +203,7 @@ const verifyingSaved = ref(false) const sendingSavedCode = ref(false) const verifyCountdown = ref(0) let verifyTimer: ReturnType | null = null +let disposed = false const canAddMore = computed(() => { return emailEntries.value.length + pendingEmails.value.length < maxTotalEmails @@ -220,6 +221,7 @@ onMounted(() => { }) onUnmounted(() => { + disposed = true for (const pe of pendingEmails.value) { if (pe.timer) clearInterval(pe.timer) } @@ -281,6 +283,7 @@ async function sendCodeFor(idx: number) { pe.sending = true try { await userAPI.sendNotifyEmailCode(pe.email) + if (disposed || !pendingEmails.value.includes(pe)) return pe.codeSent = true pe.countdown = 60 pe.timer = setInterval(() => { @@ -334,6 +337,7 @@ async function sendCodeForSaved(email: string) { sendingSavedCode.value = true try { await userAPI.sendNotifyEmailCode(email) + if (disposed) return verifyingEmail.value = email verifyCode.value = '' verifyCountdown.value = 60 diff --git a/frontend/src/components/user/profile/__tests__/ProfileBalanceNotifyCard.spec.ts b/frontend/src/components/user/profile/__tests__/ProfileBalanceNotifyCard.spec.ts index 395d8ae23a3a..03bb8cffeb86 100644 --- a/frontend/src/components/user/profile/__tests__/ProfileBalanceNotifyCard.spec.ts +++ b/frontend/src/components/user/profile/__tests__/ProfileBalanceNotifyCard.spec.ts @@ -44,6 +44,39 @@ describe('ProfileBalanceNotifyCard', () => { vi.useRealTimers() }) + it.each(['remove', 'unmount'])('does not start a pending email timer after %s', async (action) => { + const request = deferred() + sendNotifyEmailCode.mockReturnValueOnce(request.promise) + const wrapper = mount(ProfileBalanceNotifyCard, { + props: { enabled: true, threshold: null, extraEmails: [], systemDefaultThreshold: 5, userEmail: '' } + }) + await wrapper.get('input[type="email"]').setValue('new@example.com') + await button(wrapper, 'common.add').trigger('click') + await button(wrapper, 'profile.balanceNotify.sendCode').trigger('click') + if (action === 'remove') await button(wrapper, 'profile.balanceNotify.removeEmail').trigger('click') + else wrapper.unmount() + + request.resolve() + await flushPromises() + expect(vi.getTimerCount()).toBe(0) + }) + + it('does not start a saved email timer after unmount', async () => { + const request = deferred() + sendNotifyEmailCode.mockReturnValueOnce(request.promise) + const wrapper = mount(ProfileBalanceNotifyCard, { + props: { + enabled: true, threshold: null, systemDefaultThreshold: 5, userEmail: '', + extraEmails: [{ email: 'saved@example.com', disabled: false, verified: false }] + } + }) + await button(wrapper, 'profile.balanceNotify.verify').trigger('click') + wrapper.unmount() + request.resolve() + await flushPromises() + expect(vi.getTimerCount()).toBe(0) + }) + it.each([0, 1])('removes only verified emails when request %i finishes first', async (first) => { const emails = ['first@example.com', 'second@example.com', 'third@example.com'] const requests = [deferred(), deferred()] From 7d0bf9c87fdf1cf0572895f6e7aac098c4f094ed Mon Sep 17 00:00:00 2001 From: "chihao.ou" Date: Wed, 30 Sep 2026 02:24:37 +0800 Subject: [PATCH 05/88] fix(usage): preserve zero user multipliers in cost details --- .../src/components/admin/usage/UsageTable.vue | 2 +- .../admin/usage/__tests__/UsageTable.spec.ts | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/admin/usage/UsageTable.vue b/frontend/src/components/admin/usage/UsageTable.vue index 364a3072e608..26565f28ad96 100644 --- a/frontend/src/components/admin/usage/UsageTable.vue +++ b/frontend/src/components/admin/usage/UsageTable.vue @@ -497,7 +497,7 @@
{{ t('usage.rate') }} - {{ formatMultiplier(tooltipData?.rate_multiplier || 1) }}x + {{ formatMultiplier(tooltipData?.rate_multiplier ?? 1) }}x
{{ t('usage.original') }} diff --git a/frontend/src/components/admin/usage/__tests__/UsageTable.spec.ts b/frontend/src/components/admin/usage/__tests__/UsageTable.spec.ts index d915fa4c42fd..0b06dd80d391 100644 --- a/frontend/src/components/admin/usage/__tests__/UsageTable.spec.ts +++ b/frontend/src/components/admin/usage/__tests__/UsageTable.spec.ts @@ -225,6 +225,22 @@ describe('admin UsageTable tooltip', () => { expect(wrapper.get('[data-testid="native-compaction-badge"]').text()).toBe('Compaction') }) + it.each([ + [0, '0.0x'], + [0.5, '0.50x'], + [undefined, '1.00x'], + ])('shows the stored user rate %s in cost details', async (rate, expected) => { + const wrapper = mount(UsageTable, { + props: { data: [{ ...baseImageRow, rate_multiplier: rate }], loading: false, columns: [] }, + global: { stubs: { DataTable: DataTableStub, EmptyState: true, Icon: true, Teleport: true } }, + }) + const triggers = wrapper.findAll('.group.relative') + await triggers[triggers.length - 1].trigger('mouseenter') + const rateLabel = wrapper.get('.fixed').findAll('span').find(span => span.text() === 'Rate')! + expect(rateLabel.element.parentElement?.textContent).toContain(expected) + wrapper.unmount() + }) + it('shows service tier and billing breakdown in cost tooltip', async () => { const row = { request_id: 'req-admin-1', From 3a1a37b7b9e12aaf5a7547966394750b85629097 Mon Sep 17 00:00:00 2001 From: "chihao.ou" Date: Wed, 30 Sep 2026 02:42:28 +0800 Subject: [PATCH 06/88] fix(ops): retain byte units for sub-byte throughput --- frontend/src/utils/__tests__/formatBytes.spec.ts | 13 +++++++++++++ frontend/src/utils/format.ts | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) create mode 100644 frontend/src/utils/__tests__/formatBytes.spec.ts diff --git a/frontend/src/utils/__tests__/formatBytes.spec.ts b/frontend/src/utils/__tests__/formatBytes.spec.ts new file mode 100644 index 000000000000..0d22eede5ee3 --- /dev/null +++ b/frontend/src/utils/__tests__/formatBytes.spec.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest' +import { formatBytes } from '../format' +import { formatByteRate } from '@/views/admin/ops/utils/opsFormatters' + +describe('fractional byte rates', () => { + it.each([[0.5, '0.5 Bytes'], [0.01, '0.01 Bytes'], [0, '0 Bytes'], [1024, '1 KB']])('formats %s bytes', (value, expected) => { + expect(formatBytes(value as number)).toBe(expected) + }) + + it('keeps the byte unit when an observation averages less than one byte per second', () => { + expect(formatByteRate(30, 1)).toBe('0.5 Bytes/s') + }) +}) diff --git a/frontend/src/utils/format.ts b/frontend/src/utils/format.ts index 87204ff158e4..980f014c7dcf 100644 --- a/frontend/src/utils/format.ts +++ b/frontend/src/utils/format.ts @@ -87,7 +87,7 @@ export function formatBytes(bytes: number, decimals: number = 2): string { const dm = decimals < 0 ? 0 : decimals const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] - const i = Math.floor(Math.log(bytes) / Math.log(k)) + const i = Math.max(0, Math.floor(Math.log(bytes) / Math.log(k))) return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i] } From cee76030be9ff629605901007481adc80a06242a Mon Sep 17 00:00:00 2001 From: "chihao.ou" Date: Wed, 30 Sep 2026 02:45:09 +0800 Subject: [PATCH 07/88] fix(announcements): report failed read confirmations accurately --- .../components/common/AnnouncementBell.vue | 7 +++- .../common/__tests__/AnnouncementBell.spec.ts | 42 +++++++++++++++++++ frontend/src/stores/announcements.ts | 2 + 3 files changed, 49 insertions(+), 2 deletions(-) create mode 100644 frontend/src/components/common/__tests__/AnnouncementBell.spec.ts diff --git a/frontend/src/components/common/AnnouncementBell.vue b/frontend/src/components/common/AnnouncementBell.vue index e80e449f28c4..afe06ae15bd9 100644 --- a/frontend/src/components/common/AnnouncementBell.vue +++ b/frontend/src/components/common/AnnouncementBell.vue @@ -373,14 +373,17 @@ function closeDetail() { async function markAsRead(id: number) { try { - await announcementStore.markAsRead(id) + const marked = await announcementStore.markAsRead(id) + if (!marked) appStore.showError(t('common.unknownError')) + return marked } catch (err: any) { appStore.showError(err?.message || t('common.unknownError')) + return false } } async function markAsReadAndClose(id: number) { - await markAsRead(id) + if (!await markAsRead(id)) return appStore.showSuccess(t('announcements.markedAsRead')) closeDetail() } diff --git a/frontend/src/components/common/__tests__/AnnouncementBell.spec.ts b/frontend/src/components/common/__tests__/AnnouncementBell.spec.ts new file mode 100644 index 000000000000..ce729ff60474 --- /dev/null +++ b/frontend/src/components/common/__tests__/AnnouncementBell.spec.ts @@ -0,0 +1,42 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import AnnouncementBell from '../AnnouncementBell.vue' +import { useAnnouncementStore } from '@/stores/announcements' + +const { markRead, showError, showSuccess } = vi.hoisted(() => ({ markRead: vi.fn(), showError: vi.fn(), showSuccess: vi.fn() })) +vi.mock('@/api', () => ({ announcementsAPI: { markRead } })) +vi.mock('@/stores/app', () => ({ useAppStore: () => ({ showError, showSuccess }) })) +vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) })) +vi.mock('@/utils/format', () => ({ formatRelativeTime: () => 'now', formatRelativeWithDateTime: () => 'now' })) +enableAutoUnmount(afterEach) +beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + vi.spyOn(console, 'error').mockImplementation(() => {}) + useAnnouncementStore().announcements = [{ id: 1, title: 'Notice', content: 'Details', notify_mode: 'silent', created_at: '2026-09-30', updated_at: '2026-09-30' }] +}) +afterEach(() => { vi.restoreAllMocks(); document.body.style.overflow = '' }) + +describe('announcement read confirmation', () => { + it('keeps a failed read confirmation open and only reports success after retry', async () => { + markRead.mockRejectedValue(new Error('offline')) + const wrapper = mount(AnnouncementBell, { global: { stubs: { Teleport: true, Transition: true, Icon: true } } }) + await wrapper.get('button').trigger('click') + await wrapper.get('.group.relative').trigger('click') + await flushPromises() + const confirm = () => wrapper.findAll('button').find(button => button.text() === 'announcements.markRead')! + await confirm().trigger('click') + await flushPromises() + expect(markRead).toHaveBeenCalledTimes(2) + expect(showSuccess).not.toHaveBeenCalled() + expect(showError).toHaveBeenCalled() + expect(wrapper.find('.markdown-body').exists()).toBe(true) + markRead.mockResolvedValue(undefined) + await confirm().trigger('click') + await flushPromises() + expect(showSuccess).toHaveBeenCalledWith('announcements.markedAsRead') + expect(wrapper.find('.markdown-body').exists()).toBe(false) + expect(useAnnouncementStore().unreadCount).toBe(0) + }) +}) diff --git a/frontend/src/stores/announcements.ts b/frontend/src/stores/announcements.ts index e8dac58ad54e..9ebba0b6bdbf 100644 --- a/frontend/src/stores/announcements.ts +++ b/frontend/src/stores/announcements.ts @@ -96,8 +96,10 @@ export const useAnnouncementStore = defineStore('announcements', () => { if (ann) { ann.read_at = new Date().toISOString() } + return true } catch (err: any) { console.error('Failed to mark announcement as read:', err) + return false } } From 8bc375b59266946582cffb819e0b1b8f803be460 Mon Sep 17 00:00:00 2001 From: "chihao.ou" Date: Wed, 30 Sep 2026 02:45:26 +0800 Subject: [PATCH 08/88] fix(batch-image): allow access lookup retries after request failures --- .../useBatchImageAccess.retry.spec.ts | 32 +++++++++++++++++++ .../src/composables/useBatchImageAccess.ts | 2 +- 2 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 frontend/src/composables/__tests__/useBatchImageAccess.retry.spec.ts diff --git a/frontend/src/composables/__tests__/useBatchImageAccess.retry.spec.ts b/frontend/src/composables/__tests__/useBatchImageAccess.retry.spec.ts new file mode 100644 index 000000000000..1f2ce2c913a6 --- /dev/null +++ b/frontend/src/composables/__tests__/useBatchImageAccess.retry.spec.ts @@ -0,0 +1,32 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const list = vi.hoisted(() => vi.fn()) +vi.mock('@/api/keys', () => ({ keysAPI: { list } })) +vi.mock('@/stores/auth', () => ({ useAuthStore: () => ({ isAuthenticated: true }) })) + +beforeEach(() => { vi.resetModules(); list.mockReset() }) + +describe('batch image access lookup', () => { + it('retries a failed lookup when another consumer requests access', async () => { + list.mockRejectedValueOnce(new Error('offline')).mockResolvedValueOnce({ + items: [{ status: 'active', group: { platform: 'gemini', allow_batch_image_generation: true } }], pages: 1, + }) + const { useBatchImageAccess } = await import('../useBatchImageAccess') + const access = useBatchImageAccess() + expect(await access.refreshBatchImageAccess()).toBe(false) + expect(access.batchImageAccessLoaded.value).toBe(false) + expect(await useBatchImageAccess().refreshBatchImageAccess()).toBe(true) + expect(list).toHaveBeenCalledTimes(2) + expect(access.canUseBatchImage.value).toBe(true) + }) + + it('continues caching a successful lookup with no eligible keys', async () => { + list.mockResolvedValue({ items: [], pages: 1 }) + const { useBatchImageAccess } = await import('../useBatchImageAccess') + const access = useBatchImageAccess() + expect(await access.refreshBatchImageAccess()).toBe(false) + expect(access.batchImageAccessLoaded.value).toBe(true) + expect(await access.refreshBatchImageAccess()).toBe(false) + expect(list).toHaveBeenCalledTimes(1) + }) +}) diff --git a/frontend/src/composables/useBatchImageAccess.ts b/frontend/src/composables/useBatchImageAccess.ts index caf1e8c0f939..381fdc5438e0 100644 --- a/frontend/src/composables/useBatchImageAccess.ts +++ b/frontend/src/composables/useBatchImageAccess.ts @@ -60,7 +60,7 @@ async function loadBatchImageAccess(force = false): Promise { })() .catch(() => { hasAllowedBatchImageKey.value = false - loaded.value = true + loaded.value = false return false }) .finally(() => { From 8a495b0a54f5202986f56e793ff741dc3f013c12 Mon Sep 17 00:00:00 2001 From: "chihao.ou" Date: Wed, 30 Sep 2026 02:45:47 +0800 Subject: [PATCH 09/88] fix(usage): ignore obsolete cleanup task list responses --- .../admin/usage/UsageCleanupDialog.vue | 9 ++- .../__tests__/UsageCleanupDialog.spec.ts | 60 +++++++++++++++++++ 2 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/admin/usage/__tests__/UsageCleanupDialog.spec.ts diff --git a/frontend/src/components/admin/usage/UsageCleanupDialog.vue b/frontend/src/components/admin/usage/UsageCleanupDialog.vue index 3218be30cc84..136510a0afa6 100644 --- a/frontend/src/components/admin/usage/UsageCleanupDialog.vue +++ b/frontend/src/components/admin/usage/UsageCleanupDialog.vue @@ -155,6 +155,7 @@ const cancelConfirmVisible = ref(false) const canceling = ref(false) const cancelTarget = ref(null) let pollTimer: number | null = null +let taskRequestId = 0 const noop = () => {} @@ -183,6 +184,7 @@ const stopPolling = () => { } const handleClose = () => { + taskRequestId++ stopPolling() confirmVisible.value = false cancelConfirmVisible.value = false @@ -237,12 +239,14 @@ const getUserTimezone = () => { const loadTasks = async () => { if (!props.show) return + const requestId = ++taskRequestId tasksLoading.value = true try { const res = await adminUsageAPI.listCleanupTasks({ page: tasksPage.value, page_size: tasksPageSize.value }) + if (requestId !== taskRequestId) return tasks.value = res.items || [] tasksTotal.value = res.total || 0 if (res.page) { @@ -252,10 +256,11 @@ const loadTasks = async () => { tasksPageSize.value = res.page_size } } catch (error) { + if (requestId !== taskRequestId) return console.error('Failed to load cleanup tasks:', error) appStore.showError(t('admin.usage.cleanup.loadFailed')) } finally { - tasksLoading.value = false + if (requestId === taskRequestId) tasksLoading.value = false } } @@ -376,12 +381,14 @@ watch( loadTasks() startPolling() } else { + taskRequestId++ stopPolling() } } ) onUnmounted(() => { + taskRequestId++ stopPolling() }) diff --git a/frontend/src/components/admin/usage/__tests__/UsageCleanupDialog.spec.ts b/frontend/src/components/admin/usage/__tests__/UsageCleanupDialog.spec.ts new file mode 100644 index 000000000000..70f526af4ef9 --- /dev/null +++ b/frontend/src/components/admin/usage/__tests__/UsageCleanupDialog.spec.ts @@ -0,0 +1,60 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils' +import UsageCleanupDialog from '../UsageCleanupDialog.vue' +import Pagination from '@/components/common/Pagination.vue' + +const { listCleanupTasks, showError } = vi.hoisted(() => ({ listCleanupTasks: vi.fn(), showError: vi.fn() })) +vi.mock('@/components/admin/usage/UsageFilters.vue', () => ({ default: { template: '
' } })) +vi.mock('@/api/admin/usage', () => ({ adminUsageAPI: { listCleanupTasks } })) +vi.mock('@/stores/app', () => ({ useAppStore: () => ({ showError }) })) +vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) })) +enableAutoUnmount(afterEach) +beforeEach(() => { vi.clearAllMocks(); vi.spyOn(console, 'error').mockImplementation(() => {}) }) +afterEach(() => { vi.restoreAllMocks() }) +const page = (number: number) => ({ items: [{ id: number, status: 'succeeded', deleted_rows: number, filters: {} }], total: 15, page: number, page_size: 5 }) +async function openDialog() { + listCleanupTasks.mockResolvedValueOnce(page(1)) + const wrapper = mount(UsageCleanupDialog, { + props: { show: false, filters: {}, startDate: '2026-09-01', endDate: '2026-09-02' }, + global: { stubs: { BaseDialog: { template: '
' }, UsageFilters: true, ConfirmDialog: true, Pagination: true } }, + }) + await wrapper.setProps({ show: true }) + await flushPromises() + return wrapper +} + +describe('cleanup task pagination', () => { + it.each(['success', 'failure'])('ignores an older page request that completes with %s', async (outcome) => { + const wrapper = await openDialog() + let resolve!: (value: object) => void + let reject!: (error: Error) => void + listCleanupTasks.mockImplementationOnce(() => new Promise((res, rej) => { resolve = res; reject = rej })) + wrapper.findComponent(Pagination).vm.$emit('update:page', 2) + await flushPromises() + listCleanupTasks.mockResolvedValueOnce(page(3)) + wrapper.findComponent(Pagination).vm.$emit('update:page', 3) + await flushPromises() + if (outcome === 'success') resolve(page(2)) + else reject(new Error('old failure')) + await flushPromises() + expect(wrapper.findComponent(Pagination).props('page')).toBe(3) + expect(wrapper.text()).toContain('#3') + expect(showError).not.toHaveBeenCalled() + }) + + it('ignores a pending result after the dialog is closed and reopened', async () => { + const wrapper = await openDialog() + let resolve!: (value: object) => void + listCleanupTasks.mockImplementationOnce(() => new Promise(res => { resolve = res })) + wrapper.findComponent(Pagination).vm.$emit('update:page', 2) + await flushPromises() + await wrapper.setProps({ show: false }) + listCleanupTasks.mockResolvedValueOnce(page(1)) + await wrapper.setProps({ show: true }) + await flushPromises() + resolve(page(2)) + await flushPromises() + expect(wrapper.findComponent(Pagination).props('page')).toBe(1) + expect(wrapper.text()).toContain('#1') + }) +}) From 8167d85b32480de46fd140dc91b1c7d35742c6a1 Mon Sep 17 00:00:00 2001 From: "chihao.ou" Date: Wed, 30 Sep 2026 02:46:31 +0800 Subject: [PATCH 10/88] fix(ui): close selectors when they become disabled --- .../src/components/common/ProxySelector.vue | 9 ++++- frontend/src/components/common/Select.vue | 4 ++ .../__tests__/disabledSelectors.spec.ts | 38 +++++++++++++++++++ 3 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/common/__tests__/disabledSelectors.spec.ts diff --git a/frontend/src/components/common/ProxySelector.vue b/frontend/src/components/common/ProxySelector.vue index 986609b35b3e..a441d6ceeaa5 100644 --- a/frontend/src/components/common/ProxySelector.vue +++ b/frontend/src/components/common/ProxySelector.vue @@ -168,7 +168,7 @@ diff --git a/frontend/src/components/auth/__tests__/PendingOAuthCreateAccountForm.spec.ts b/frontend/src/components/auth/__tests__/PendingOAuthCreateAccountForm.spec.ts index a1a5841ea5b4..b006a4a3fd89 100644 --- a/frontend/src/components/auth/__tests__/PendingOAuthCreateAccountForm.spec.ts +++ b/frontend/src/components/auth/__tests__/PendingOAuthCreateAccountForm.spec.ts @@ -51,6 +51,30 @@ describe('PendingOAuthCreateAccountForm', () => { }) }) + it('does not restart the code cooldown after switching away during a send', async () => { + vi.useFakeTimers() + let finish!: (value: { countdown: number }) => void + sendPendingOAuthVerifyCode.mockImplementation(() => new Promise(resolve => { finish = resolve })) + const wrapper = mount(PendingOAuthCreateAccountForm, { + props: { testIdPrefix: 'oidc', initialEmail: 'user@example.com', isSubmitting: false }, + global: { stubs: { TurnstileWidget: true } }, + }) + try { + await flushPromises() + await wrapper.get('[data-testid="oidc-create-account-send-code"]').trigger('click') + await flushPromises() + expect(sendPendingOAuthVerifyCode).toHaveBeenCalledTimes(1) + wrapper.unmount() + finish({ countdown: 60 }) + await flushPromises() + expect(vi.getTimerCount()).toBe(0) + } finally { + wrapper.unmount() + vi.clearAllTimers() + vi.useRealTimers() + } + }) + it('acquires separate proofs for pending OAuth send-code and create-account', async () => { getPublicSettings.mockResolvedValue({ email_verify_enabled: true, From 00a922c429db9c822b55aabc8f38d321a451cafd Mon Sep 17 00:00:00 2001 From: "chihao.ou" Date: Wed, 30 Sep 2026 02:50:29 +0800 Subject: [PATCH 12/88] fix(backup): prevent polling from restarting after navigation --- frontend/src/views/admin/BackupView.vue | 4 ++++ .../views/admin/__tests__/BackupView.spec.ts | 21 +++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/frontend/src/views/admin/BackupView.vue b/frontend/src/views/admin/BackupView.vue index b9f7600a1902..82fbdc56abc6 100644 --- a/frontend/src/views/admin/BackupView.vue +++ b/frontend/src/views/admin/BackupView.vue @@ -537,6 +537,7 @@ const downloadPartsModalOpen = ref(false) const pollingTimer = ref | null>(null) const restoringPollingTimer = ref | null>(null) const MAX_POLL_COUNT = 900 +let disposed = false function updateRecordInList(updated: BackupRecord) { const idx = backups.value.findIndex(r => r.id === updated.id) @@ -546,6 +547,7 @@ function updateRecordInList(updated: BackupRecord) { } function startPolling(backupId: string) { + if (disposed) return stopPolling() let count = 0 pollingTimer.value = setInterval(async () => { @@ -582,6 +584,7 @@ function stopPolling() { } function startRestorePolling(backupId: string) { + if (disposed) return stopRestorePolling() let count = 0 restoringPollingTimer.value = setInterval(async () => { @@ -930,6 +933,7 @@ onMounted(async () => { }) onBeforeUnmount(() => { + disposed = true stopPolling() stopRestorePolling() document.removeEventListener('visibilitychange', handleVisibilityChange) diff --git a/frontend/src/views/admin/__tests__/BackupView.spec.ts b/frontend/src/views/admin/__tests__/BackupView.spec.ts index 8c1e1563bc02..933e70228ce5 100644 --- a/frontend/src/views/admin/__tests__/BackupView.spec.ts +++ b/frontend/src/views/admin/__tests__/BackupView.spec.ts @@ -108,6 +108,27 @@ describe('admin BackupView', () => { document.body.innerHTML = '' }) + it.each(['backup', 'restore'])('does not resume %s polling after navigation during initial loading', async (operation) => { + vi.useFakeTimers() + let finish!: (value: object) => void + listBackups.mockImplementationOnce(() => new Promise(resolve => { finish = resolve })) + const wrapper = mountBackupView() + try { + await flushPromises() + wrapper.unmount() + finish({ items: [{ ...baseRecord('pending'), + status: operation === 'backup' ? 'running' : 'completed', + restore_status: operation === 'restore' ? 'running' : undefined, + }] }) + await flushPromises() + expect(vi.getTimerCount()).toBe(0) + } finally { + wrapper.unmount() + vi.clearAllTimers() + vi.useRealTimers() + } + }) + it('显示分卷数并在下载时列出每个分卷链接', async () => { listBackups.mockResolvedValue({ items: [baseRecord('split', [{ index: 1 }, { index: 2 }, { index: 3 }])], From e0019da463226defa0c1efe0ca788a9e38f21044 Mon Sep 17 00:00:00 2001 From: "chihao.ou" Date: Wed, 30 Sep 2026 02:51:49 +0800 Subject: [PATCH 13/88] fix(ui): limit Escape to the most recently opened dialog --- frontend/src/components/common/BaseDialog.vue | 2 +- .../__tests__/BaseDialog.escape.spec.ts | 30 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/common/__tests__/BaseDialog.escape.spec.ts diff --git a/frontend/src/components/common/BaseDialog.vue b/frontend/src/components/common/BaseDialog.vue index ba0fe6a05167..5bdef2ba62ab 100644 --- a/frontend/src/components/common/BaseDialog.vue +++ b/frontend/src/components/common/BaseDialog.vue @@ -111,7 +111,7 @@ const handleClose = () => { } const handleEscape = (event: KeyboardEvent) => { - if (props.show && props.closeOnEscape && event.key === 'Escape') { + if (props.show && props.closeOnEscape && event.key === 'Escape' && [...openDialogs].pop() === dialogId) { emit('close') } } diff --git a/frontend/src/components/common/__tests__/BaseDialog.escape.spec.ts b/frontend/src/components/common/__tests__/BaseDialog.escape.spec.ts new file mode 100644 index 000000000000..dadf10f6211f --- /dev/null +++ b/frontend/src/components/common/__tests__/BaseDialog.escape.spec.ts @@ -0,0 +1,30 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { enableAutoUnmount, mount } from '@vue/test-utils' +import BaseDialog from '../BaseDialog.vue' + +enableAutoUnmount(afterEach) +const pressEscape = () => document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })) + +describe('stacked dialog Escape handling', () => { + it('closes only the most recently opened dialog, then allows the parent to close', async () => { + const parent = mount(BaseDialog, { props: { show: true, title: 'Parent' } }) + const child = mount(BaseDialog, { props: { show: true, title: 'Child' } }) + pressEscape() + expect(child.emitted('close')).toHaveLength(1) + expect(parent.emitted('close')).toBeUndefined() + await child.setProps({ show: false }) + pressEscape() + expect(parent.emitted('close')).toHaveLength(1) + }) + + it('does not dismiss a parent behind a child that disallows Escape', () => { + const parent = mount(BaseDialog, { props: { show: true, title: 'Parent' } }) + const child = mount(BaseDialog, { props: { show: true, title: 'Child', closeOnEscape: false } }) + pressEscape() + expect(child.emitted('close')).toBeUndefined() + expect(parent.emitted('close')).toBeUndefined() + child.unmount() + pressEscape() + expect(parent.emitted('close')).toHaveLength(1) + }) +}) From c641453123c363f286e94b35611691212e5e651c Mon Sep 17 00:00:00 2001 From: "chihao.ou" Date: Wed, 30 Sep 2026 02:52:50 +0800 Subject: [PATCH 14/88] fix(auth): share pending step-up verification prompts --- .../composables/__tests__/useStepUp.spec.ts | 21 +++++++++++++++++++ frontend/src/composables/useStepUp.ts | 7 ++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/frontend/src/composables/__tests__/useStepUp.spec.ts b/frontend/src/composables/__tests__/useStepUp.spec.ts index df0482879a65..07f01de48883 100644 --- a/frontend/src/composables/__tests__/useStepUp.spec.ts +++ b/frontend/src/composables/__tests__/useStepUp.spec.ts @@ -28,6 +28,27 @@ describe('useStepUp error classification', () => { }) }) +describe('useStepUp concurrent prompts', () => { + it.each([true, false])('settles every waiter when verification resolves to %s', async (verified) => { + const stepUp = useStepUp() + const firstDone = vi.fn() + const secondDone = vi.fn() + const first = stepUp.prompt() + first.then(firstDone) + stepUp.prompt().then(secondDone) + if (verified) stepUp.onVerified() + else stepUp.onCancel() + await Promise.resolve() + expect(firstDone).toHaveBeenCalledWith(verified) + expect(secondDone).toHaveBeenCalledWith(verified) + expect(stepUp.visible.value).toBe(false) + const next = stepUp.prompt() + expect(next).not.toBe(first) + stepUp.onCancel() + await expect(next).resolves.toBe(false) + }) +}) + describe('useStepUp.run', () => { it('returns the action result directly on success', async () => { const stepUp = useStepUp() diff --git a/frontend/src/composables/useStepUp.ts b/frontend/src/composables/useStepUp.ts index 77ae51788f28..1e1fedeb68c1 100644 --- a/frontend/src/composables/useStepUp.ts +++ b/frontend/src/composables/useStepUp.ts @@ -68,25 +68,30 @@ export function useStepUp() { const visible = ref(false) const blockedReason = ref('') let resolver: ((ok: boolean) => void) | null = null + let pendingPrompt: Promise | null = null /** Open the TOTP dialog and resolve true once a grant is obtained. */ function prompt(): Promise { + if (pendingPrompt) return pendingPrompt visible.value = true - return new Promise((resolve) => { + pendingPrompt = new Promise((resolve) => { resolver = resolve }) + return pendingPrompt } function onVerified() { visible.value = false resolver?.(true) resolver = null + pendingPrompt = null } function onCancel() { visible.value = false resolver?.(false) resolver = null + pendingPrompt = null } /** From 75420462a605f1f1ec834266b89dc8a4f2b64499 Mon Sep 17 00:00:00 2001 From: "chihao.ou" Date: Wed, 30 Sep 2026 19:10:51 +0800 Subject: [PATCH 15/88] fix(plugins): ignore obsolete configuration UI sessions --- frontend/src/views/admin/PluginsView.vue | 10 ++- .../views/admin/__tests__/PluginsView.spec.ts | 62 ++++++++++++++++++- 2 files changed, 68 insertions(+), 4 deletions(-) diff --git a/frontend/src/views/admin/PluginsView.vue b/frontend/src/views/admin/PluginsView.vue index 6c78fc69e90b..7fc17c9b43aa 100644 --- a/frontend/src/views/admin/PluginsView.vue +++ b/frontend/src/views/admin/PluginsView.vue @@ -380,6 +380,7 @@ const uiError = ref(""); const iframeHeight = ref(640); const pluginFrameLoaded = ref(false); const pendingBridgeRequests = new Map(); +let uiSessionVersion = 0; function errorMessage(error: unknown): string { if (typeof error === "object" && error !== null && "message" in error) { @@ -523,6 +524,7 @@ async function testPlugin(plugin: PluginInstallation): Promise { } async function openConfiguration(plugin: PluginInstallation): Promise { + const version = ++uiSessionVersion; configPlugin.value = plugin; uiSession.value = null; pluginFrameLoaded.value = false; @@ -531,14 +533,18 @@ async function openConfiguration(plugin: PluginInstallation): Promise { uiError.value = ""; iframeHeight.value = 640; try { - uiSession.value = await adminAPI.plugins.createUISession(plugin.id); + const session = await adminAPI.plugins.createUISession(plugin.id); + if (version !== uiSessionVersion) return; + uiSession.value = session; } catch (error: unknown) { + if (version !== uiSessionVersion) return; uiLoading.value = false; uiError.value = errorMessage(error); } } function closeConfiguration(): void { + uiSessionVersion++; clearPendingBridgeRequests(); pluginFrameLoaded.value = false; configPlugin.value = null; @@ -723,6 +729,6 @@ onMounted(() => { onBeforeUnmount(() => { window.removeEventListener("message", handleBridgeMessage); - clearPendingBridgeRequests(); + closeConfiguration(); }); diff --git a/frontend/src/views/admin/__tests__/PluginsView.spec.ts b/frontend/src/views/admin/__tests__/PluginsView.spec.ts index 3fa4a7ed294d..2504964aa55b 100644 --- a/frontend/src/views/admin/__tests__/PluginsView.spec.ts +++ b/frontend/src/views/admin/__tests__/PluginsView.spec.ts @@ -1,7 +1,11 @@ -import { flushPromises, mount } from '@vue/test-utils' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import PluginsView from '../PluginsView.vue' +import BaseDialog from '@/components/common/BaseDialog.vue' +import type { PluginUISession } from '@/api/admin' + +enableAutoUnmount(afterEach) const { listPlugins, @@ -151,6 +155,60 @@ describe('管理员插件页二次验证', () => { expect(enablePlugin).toHaveBeenCalledWith(7, 100, false) }) + it.each([7, 8])('keeps the latest session when reopening configuration for plugin %s', async (id) => { + let resolveOld!: (session: PluginUISession) => void + createUISession.mockReturnValueOnce(new Promise((resolve) => { resolveOld = resolve })) + listPlugins.mockResolvedValue([plugin, { ...plugin, id: 8, name: 'Other Transport' }]) + const wrapper = mountView() + await flushPromises() + const buttons = wrapper.findAll('button').filter(item => item.text() === 'admin.plugins.configure') + await buttons[0].trigger('click') + wrapper.getComponent(BaseDialog).vm.$emit('close') + await buttons[id === 7 ? 0 : 1].trigger('click') + await flushPromises() + const currentURL = wrapper.get('iframe').attributes('src') + + resolveOld({ url: '/obsolete-session', bridge_token: 'old', ui_bridge_version: 1, expires_at: '' }) + await flushPromises() + expect(wrapper.get('iframe').attributes('src')).toBe(currentURL) + }) + + it('ignores an old session error while the reopened configuration is loading', async () => { + let rejectOld!: (error: Error) => void + let resolveCurrent!: (session: PluginUISession) => void + createUISession + .mockReturnValueOnce(new Promise((_, reject) => { rejectOld = reject })) + .mockReturnValueOnce(new Promise((resolve) => { resolveCurrent = resolve })) + const wrapper = mountView() + await flushPromises() + const button = wrapper.findAll('button').find(item => item.text() === 'admin.plugins.configure')! + await button.trigger('click') + wrapper.getComponent(BaseDialog).vm.$emit('close') + await button.trigger('click') + rejectOld(new Error('obsolete failure')) + await flushPromises() + + expect(wrapper.text()).not.toContain('obsolete failure') + expect(wrapper.text()).toContain('admin.plugins.loadingUI') + resolveCurrent({ url: '/current-session', bridge_token: 'current', ui_bridge_version: 1, expires_at: '' }) + await flushPromises() + await wrapper.get('iframe').trigger('load') + expect(wrapper.get('iframe').attributes('src')).toBe('/current-session') + expect(wrapper.text()).not.toContain('admin.plugins.loadingUI') + }) + + it('does not restore a session after the configuration dialog closes', async () => { + let resolveSession!: (session: PluginUISession) => void + createUISession.mockReturnValueOnce(new Promise((resolve) => { resolveSession = resolve })) + const wrapper = mountView() + await flushPromises() + await wrapper.findAll('button').find(item => item.text() === 'admin.plugins.configure')!.trigger('click') + wrapper.getComponent(BaseDialog).vm.$emit('close') + resolveSession({ url: '/closed-session', bridge_token: 'old', ui_bridge_version: 1, expires_at: '' }) + await flushPromises() + expect(wrapper.find('iframe').exists()).toBe(false) + }) + it('上传插件通过 step-up 控制器执行', async () => { const wrapper = mountView() await flushPromises() From 21d90fb5e52cf8f4689973dfe569953f525442e2 Mon Sep 17 00:00:00 2001 From: "chihao.ou" Date: Wed, 30 Sep 2026 19:11:19 +0800 Subject: [PATCH 16/88] fix(auth): keep cleared compliance state isolated from pending requests --- .../__tests__/adminCompliance.reset.spec.ts | 83 +++++++++++++++++++ frontend/src/stores/adminCompliance.ts | 10 ++- 2 files changed, 91 insertions(+), 2 deletions(-) create mode 100644 frontend/src/stores/__tests__/adminCompliance.reset.spec.ts diff --git a/frontend/src/stores/__tests__/adminCompliance.reset.spec.ts b/frontend/src/stores/__tests__/adminCompliance.reset.spec.ts new file mode 100644 index 000000000000..81e1fadf4a50 --- /dev/null +++ b/frontend/src/stores/__tests__/adminCompliance.reset.spec.ts @@ -0,0 +1,83 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' +import type { AdminComplianceStatus } from '@/api/admin/compliance' +import { useAdminComplianceStore } from '../adminCompliance' + +const api = vi.hoisted(() => ({ getStatus: vi.fn(), accept: vi.fn() })) +vi.mock('@/api/admin/compliance', () => ({ default: api })) +vi.mock('@/i18n', () => ({ getLocale: () => 'en' })) + +function status(required: boolean, version = 'current'): AdminComplianceStatus { + return { + required, version, document_path_zh: '', document_path_en: '', + document_url_zh: '', document_url_en: '', ack_phrase_zh: '测试', ack_phrase_en: 'test' + } +} + +function deferred() { + let resolve!: (value: AdminComplianceStatus) => void + let reject!: (error: Error) => void + const promise = new Promise((yes, no) => { resolve = yes; reject = no }) + return { promise, resolve, reject } +} + +beforeEach(() => { + setActivePinia(createPinia()) + vi.resetAllMocks() +}) + +describe.each(['fetch', 'accept'] as const)('compliance %s across logout', (action) => { + const mock = () => action === 'fetch' ? api.getStatus : api.accept + const run = (store: ReturnType) => + action === 'fetch' ? store.fetchStatus() : store.accept('test') + const busy = (store: ReturnType) => + action === 'fetch' ? store.loading : store.submitting + + it('does not repopulate cleared state when an old request succeeds', async () => { + const pending = deferred() + mock().mockReturnValueOnce(pending.promise) + const store = useAdminComplianceStore() + const request = run(store) + store.reset() + const response = status(true, 'old') + pending.resolve(response) + expect(await request).toEqual(response) + expect(store.status).toBeNull() + expect(store.initialized).toBe(false) + expect(store.shouldShow).toBe(false) + expect(busy(store)).toBe(false) + }) + + it.each(['resolve', 'reject'] as const)('keeps a new session pending when the old request %s', async (outcome) => { + const old = deferred() + const current = deferred() + mock().mockReturnValueOnce(old.promise).mockReturnValueOnce(current.promise) + const store = useAdminComplianceStore() + const first = run(store).catch(error => error) + store.reset() + const second = run(store) + if (outcome === 'resolve') old.resolve(status(false, 'old')) + else old.reject(new Error('old failure')) + await first + expect(busy(store)).toBe(true) + expect(store.status).toBeNull() + current.resolve(status(true)) + await second + expect(busy(store)).toBe(false) + expect(store.status).toEqual(status(true)) + expect(store.shouldShow).toBe(true) + }) +}) + +it('still updates compliance status during a normal fetch and acceptance', async () => { + api.getStatus.mockResolvedValue(status(true)) + api.accept.mockResolvedValue(status(false)) + const store = useAdminComplianceStore() + await store.fetchStatus() + expect(store.initialized).toBe(true) + expect(store.shouldShow).toBe(true) + await store.accept('test') + expect(api.accept).toHaveBeenCalledWith({ phrase: 'test', language: 'en' }) + expect(store.status).toEqual(status(false)) + expect(store.shouldShow).toBe(false) +}) diff --git a/frontend/src/stores/adminCompliance.ts b/frontend/src/stores/adminCompliance.ts index 0d2cf76b5fca..82ecb9381b30 100644 --- a/frontend/src/stores/adminCompliance.ts +++ b/frontend/src/stores/adminCompliance.ts @@ -12,6 +12,7 @@ export const useAdminComplianceStore = defineStore('adminCompliance', () => { const submitting = ref(false) const initialized = ref(false) const forceVisible = ref(false) + let stateVersion = 0 const required = computed(() => status.value?.required === true) const shouldShow = computed(() => required.value || forceVisible.value) @@ -24,30 +25,34 @@ export const useAdminComplianceStore = defineStore('adminCompliance', () => { }) async function fetchStatus(): Promise { + const version = stateVersion loading.value = true try { const nextStatus = await adminComplianceAPI.getStatus() + if (version !== stateVersion) return nextStatus status.value = nextStatus initialized.value = true forceVisible.value = nextStatus.required return nextStatus } finally { - loading.value = false + if (version === stateVersion) loading.value = false } } async function accept(phrase: string): Promise { + const version = stateVersion submitting.value = true try { const nextStatus = await adminComplianceAPI.accept({ phrase, language: currentLocale.value }) + if (version !== stateVersion) return nextStatus status.value = nextStatus forceVisible.value = nextStatus.required return nextStatus } finally { - submitting.value = false + if (version === stateVersion) submitting.value = false } } @@ -68,6 +73,7 @@ export const useAdminComplianceStore = defineStore('adminCompliance', () => { } function reset(): void { + stateVersion++ status.value = null loading.value = false submitting.value = false From 3613174cf653b77508c0ed10eda816d658c928c1 Mon Sep 17 00:00:00 2001 From: YahooYuan666 Date: Thu, 1 Oct 2026 02:16:22 +0800 Subject: [PATCH 17/88] fix(grok): fail over silent-refusal empty response.completed on Grok Responses streams Issue #5009 added a failover guard for OpenAI Responses streams that terminate with an empty response.completed (no output, no usage, no error), but the guard is gated on PlatformOpenAI. Grok platform accounts reach the same handler (forwardGrokResponses -> handleStreamingResponseWithReasoning) and exhibit the same upstream silent-refusal shape, so their empty completions are relayed to the client as successful empty turns: Responses SDK clients map the empty completed to finishReason stop and accept the turn with no retry. Extend the guard to PlatformGrok, matching the unconditional guard on the passthrough path (openai_gateway_passthrough.go). Fixes #7774 --- .../internal/service/openai_gateway_response_handling.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/backend/internal/service/openai_gateway_response_handling.go b/backend/internal/service/openai_gateway_response_handling.go index 95e5db31934f..d4c8f723b636 100644 --- a/backend/internal/service/openai_gateway_response_handling.go +++ b/backend/internal/service/openai_gateway_response_handling.go @@ -687,8 +687,12 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context. // OpenAI Responses streams that terminate with an empty // response.completed (no output, no usage, no error, nothing sent // to the client) are silent upstream refusals: fail over instead of - // recording a successful 0/0 usage turn (issue #5009). - if account != nil && account.Platform == PlatformOpenAI && + // recording a successful 0/0 usage turn (issue #5009). Grok + // subscriptions behind vendor gateways show the same silent-refusal + // shape on /v1/responses (forwardGrokResponses reaches this same + // handler), so cover PlatformGrok too — the passthrough-path guard + // is already unconditional. + if account != nil && (account.Platform == PlatformOpenAI || account.Platform == PlatformGrok) && (eventType == "response.completed" || eventType == "response.done") && !sawFailedEvent && !responsesSemanticOutputSeen && !clientOutputStarted && openAIResponsesCompletedEventIsEmpty(dataBytes, usage) { From 27f4a1780fc7dddec8dccef1a5f06bfaa445645f Mon Sep 17 00:00:00 2001 From: YahooYuan666 Date: Thu, 1 Oct 2026 03:01:43 +0800 Subject: [PATCH 18/88] test(grok): cover empty response.completed failover for PlatformGrok Adds two regression cases mirroring the #5009 OpenAI ones for Grok platform accounts (issue #7774): - TestOpenAIResponsesEmptyCompletedFailsOverGrok: an empty response.completed on the forwardGrokResponses streaming path must produce UpstreamFailoverError with an empty client body. Verified to FAIL on main without the guard extension and PASS with it. - TestOpenAIResponsesEmptyCompletedWithOutputSucceedsGrok: normal Grok streams with semantic output and usage are untouched. Fixes #7774 --- ..._gateway_responses_empty_completed_test.go | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/backend/internal/service/openai_gateway_responses_empty_completed_test.go b/backend/internal/service/openai_gateway_responses_empty_completed_test.go index 55bfda089ccd..c5989136cda5 100644 --- a/backend/internal/service/openai_gateway_responses_empty_completed_test.go +++ b/backend/internal/service/openai_gateway_responses_empty_completed_test.go @@ -166,3 +166,71 @@ func TestOpenAIResponsesCompletedEventIsEmpty(t *testing.T) { }) } } + +// TestOpenAIResponsesEmptyCompletedFailsOverGrok verifies that Grok platform +// accounts — which reach the stream relay through forwardGrokResponses instead +// of the passthrough path — get the same #5009 silent-refusal failover for an +// empty response.completed (issue #7774). +func TestOpenAIResponsesEmptyCompletedFailsOverGrok(t *testing.T) { + gin.SetMode(gin.TestMode) + + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader( + "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_grok\",\"object\":\"response\",\"status\":\"in_progress\"}}\n\n" + + "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_grok\",\"object\":\"response\",\"status\":\"completed\",\"output\":[]}}\n\n", + )), + }} + svc := newOpenAIImageGenerationControlTestService(upstream) + c, recorder := newOpenAIImageGenerationControlTestContext(true, "codex_cli_rs/0.144.1") + account := newOpenAIImageGenerationControlTestAccount() + account.Platform = PlatformGrok + + body := []byte(`{ + "model":"grok-4.7", + "stream":true, + "input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"continue"}]}] + }`) + + _, err := svc.Forward(context.Background(), c, account, body) + require.Error(t, err) + var failoverErr *UpstreamFailoverError + require.True(t, errors.As(err, &failoverErr), "empty completed must produce UpstreamFailoverError, got: %v", err) + require.Equal(t, http.StatusBadGateway, failoverErr.StatusCode) + require.Empty(t, recorder.Body.String(), "no empty success stream may reach the client") +} + +// TestOpenAIResponsesEmptyCompletedWithOutputSucceedsGrok ensures normal Grok +// streams with real semantic output still succeed. +func TestOpenAIResponsesEmptyCompletedWithOutputSucceedsGrok(t *testing.T) { + gin.SetMode(gin.TestMode) + + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader( + "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_grok_ok\",\"object\":\"response\",\"status\":\"in_progress\"}}\n\n" + + "data: {\"type\":\"response.output_text.delta\",\"delta\":\"hello grok\"}\n\n" + + "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_grok_ok\",\"object\":\"response\",\"status\":\"completed\",\"usage\":{\"input_tokens\":11,\"output_tokens\":4,\"total_tokens\":15}}}\n\n", + )), + }} + svc := newOpenAIImageGenerationControlTestService(upstream) + c, recorder := newOpenAIImageGenerationControlTestContext(true, "codex_cli_rs/0.144.1") + account := newOpenAIImageGenerationControlTestAccount() + account.Platform = PlatformGrok + + body := []byte(`{ + "model":"grok-4.7", + "stream":true, + "input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"continue"}]}] + }`) + + result, err := svc.Forward(context.Background(), c, account, body) + require.NoError(t, err) + require.NotNil(t, result) + require.Contains(t, recorder.Body.String(), "hello grok") + require.NotNil(t, result.Usage) + require.Equal(t, 11, result.Usage.InputTokens) + require.Equal(t, 4, result.Usage.OutputTokens) +} From 73381fc9c7648dac3eed2f3f900a97ad225c86ff Mon Sep 17 00:00:00 2001 From: SPHINX998 <51872346+SPHINX998@users.noreply.github.com> Date: Thu, 1 Oct 2026 11:27:35 +0800 Subject: [PATCH 19/88] fix(openai-ws): bill each later turn with the current group pricing The Responses WebSocket handler reads the API key auth snapshot once at connect and reuses it for every turn: RecordUsage takes the group pricing from that snapshot, and the profit gate reinstalled in BeforeTurn takes its selling price from the connect-time ctxkey.Group. A group price change therefore never reaches an open connection until the client reconnects, while HTTP requests pick it up once the auth cache entry is invalidated. Each later turn now re-reads the key through APIKeyService.GetByKey (the auth cache the middleware uses) in BeforeTurn. When the key ID, group ID, group platform and subscription type are unchanged, the fresh group is swapped into ctxkey.Group for the turn's profit gate and passed to that turn's RecordUsage. A failed lookup or a key moved to another group keeps the connection snapshot. Co-Authored-By: Claude Opus 5.5 --- .../handler/openai_gateway_handler.go | 104 ++++++- .../handler/openai_gateway_handler_test.go | 17 + .../openai_gateway_ws_group_pricing_test.go | 293 ++++++++++++++++++ 3 files changed, 412 insertions(+), 2 deletions(-) create mode 100644 backend/internal/handler/openai_gateway_ws_group_pricing_test.go diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go index 6214aacbc392..21f0cace70fd 100644 --- a/backend/internal/handler/openai_gateway_handler.go +++ b/backend/internal/handler/openai_gateway_handler.go @@ -2125,6 +2125,101 @@ func (p *openAIWSTurnPricing) currentOr(fallback time.Time) time.Time { return fallback } +// openAIWSTurnAPIKeyLookup 是后续 turn 重取 API Key 认证快照的入口 +// (APIKeyService.GetByKey:经 L1/L2 认证缓存,未命中才回源)。 +type openAIWSTurnAPIKeyLookup interface { + GetByKey(ctx context.Context, key string) (*service.APIKey, error) +} + +// openAIWSTurnBillingAPIKeys 按 turn 号保存每个 turn 计费用的 API Key 快照。 +// +// API Key 认证快照只在建连时取一次。若连接内所有 turn 共用它,分组定价(倍率、 +// 高峰、图片定价、利润门参数)会停留在建连时刻:管理员调价后,已打开的长连接 +// 在客户端重连前一直按旧价计费、按旧售价过利润门,而 HTTP 请求每次都经认证缓存 +// 取快照,缓存失效后即生效。 +// +// 因此后续 turn 在 BeforeTurn(与 HTTP 请求进入认证中间件同位)经同一认证缓存 +// 重取快照,只采用其中的分组,作为该 turn 利润门准入与用量计费共同的分组;用户、 +// Key 限额、订阅仍沿用建连快照。只在同一把 Key、同一分组且平台与订阅类型未变时 +// 采用:Key 换组或分组改平台/订阅类型不是调价,该连接按建连分组调度,继续按建连 +// 快照计费;重取失败同样保留建连快照,不断连。 +// +// 首轮沿用刚经认证中间件取得的建连快照;没有经过 BeforeTurn 的 turn 回退建连 +// 快照。只保留当前与上一个 turn:下一 turn 的 BeforeTurn 先于上一 turn 的 +// AfterTurn 执行时,上一 turn 仍取到自己的快照。 +type openAIWSTurnBillingAPIKeys struct { + mu sync.Mutex + keys map[int]*service.APIKey +} + +// begin 在 BeforeTurn 重装利润门前调用:后续 turn 重取计费分组并按 turn 记下, +// 返回换入该分组的上下文供本 turn 的利润门使用。 +func (k *openAIWSTurnBillingAPIKeys) begin(ctx context.Context, apiKeyService *service.APIKeyService, turn int, conn *service.APIKey) context.Context { + turnKey := conn + if turn > 1 && apiKeyService != nil { + turnKey = refreshOpenAIWSTurnBillingAPIKey(ctx, apiKeyService, conn) + } + k.set(turn, turnKey) + return withOpenAIWSTurnBillingGroup(ctx, turnKey) +} + +func (k *openAIWSTurnBillingAPIKeys) set(turn int, key *service.APIKey) { + k.mu.Lock() + defer k.mu.Unlock() + if k.keys == nil { + k.keys = make(map[int]*service.APIKey, 2) + } + for t := range k.keys { + if t < turn-1 { + delete(k.keys, t) + } + } + k.keys[turn] = key +} + +func (k *openAIWSTurnBillingAPIKeys) forTurn(turn int, conn *service.APIKey) *service.APIKey { + k.mu.Lock() + defer k.mu.Unlock() + if key := k.keys[turn]; key != nil { + return key + } + return conn +} + +// refreshOpenAIWSTurnBillingAPIKey 返回本 turn 计费用的 API Key:分组取当前认证 +// 快照,其余字段与建连快照共享。不满足采用条件时原样返回建连快照。 +func refreshOpenAIWSTurnBillingAPIKey(ctx context.Context, lookup openAIWSTurnAPIKeyLookup, conn *service.APIKey) *service.APIKey { + if lookup == nil || conn == nil || conn.Key == "" || conn.GroupID == nil || conn.Group == nil { + return conn + } + latest, err := lookup.GetByKey(ctx, conn.Key) + if err != nil || latest == nil || latest.ID != conn.ID || latest.GroupID == nil || *latest.GroupID != *conn.GroupID { + return conn + } + group := latest.Group + if group == nil || group.ID != conn.Group.ID || + group.Platform != conn.Group.Platform || + group.SubscriptionType != conn.Group.SubscriptionType { + return conn + } + turnKey := *conn + turnKey.Group = group + return &turnKey +} + +// withOpenAIWSTurnBillingGroup 把本 turn 的计费分组换进认证分组上下文,使利润门 +// 的售价与本 turn 计费同源。上下文里没有同 ID 的认证分组时不改动。 +func withOpenAIWSTurnBillingGroup(ctx context.Context, turnKey *service.APIKey) context.Context { + if turnKey == nil || turnKey.Group == nil { + return ctx + } + current, ok := ctx.Value(ctxkey.Group).(*service.Group) + if !ok || current == nil || current == turnKey.Group || current.ID != turnKey.Group.ID { + return ctx + } + return context.WithValue(ctx, ctxkey.Group, turnKey.Group) +} + // recordOpenAIProfitVeto 记录 OpenAI 侧选号循环的一次利润门终检否决:把账号 // 加入本请求排除集并递增否决计数。返回 false 表示否决次数已达 // maxProfitVetoAttempts,调用方必须停止重选并按「无可用账号」终止。 @@ -2856,6 +2951,9 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { // turn 级定价:首轮回退到 TurnStarted 的所属 turn 时刻;后续 turn 由 // BeforeTurn 重新冻结 pricingAt 并按最新门复核当前账号。 var turnPricing openAIWSTurnPricing + // 后续 turn 的计费分组同样在 BeforeTurn 经认证缓存重取,使分组调价对 + // 已打开的连接生效。 + var turnBillingAPIKeys openAIWSTurnBillingAPIKeys // Passthrough ingress does not invoke BeforeTurn for the first frame. if err := checkSimpleModeTurnBilling(); err != nil { closeOpenAIClientWS(wsConn, coderws.StatusPolicyViolation, "billing check failed") @@ -2943,7 +3041,8 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { // 长连接跨峰谷/倍率刷新防护:每个 turn 按当前时刻重装门并复核 // 当前账号,越线即要求客户端重连重选(连接绑定单一上游账号, // 无法中途换号)。本 turn 的准入与计费共用同一 pricingAt。 - turnCtx, turnAt := h.gatewayService.WithOpenAITurnPricingContext(ctx, apiKey.GroupID) + turnBillingCtx := turnBillingAPIKeys.begin(ctx, h.apiKeyService, turn, apiKey) + turnCtx, turnAt := h.gatewayService.WithOpenAITurnPricingContext(turnBillingCtx, apiKey.GroupID) if _, vetoed, reason := h.gatewayService.ProfitControlVetoLatest(turnCtx, account); vetoed { reqLog.Info("openai.websocket_turn_profit_vetoed", zap.Int("turn", turn), @@ -3059,11 +3158,12 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey) sessionID := service.ExtractClientSessionID(c) turnRecordPricingAt := turnPricing.currentOr(turnStart) + turnBillingAPIKey := turnBillingAPIKeys.forTurn(turn, apiKey) cyberBlocked := service.GetOpsCyberPolicy(c) != nil h.submitOpenAIUsageRecordTask(ctx, result, func(taskCtx context.Context) { if err := h.gatewayService.RecordUsage(taskCtx, &service.OpenAIRecordUsageInput{ Result: result, - APIKey: apiKey, + APIKey: turnBillingAPIKey, User: apiKey.User, Account: account, Subscription: subscription, diff --git a/backend/internal/handler/openai_gateway_handler_test.go b/backend/internal/handler/openai_gateway_handler_test.go index cf9f1d9e7c65..894d2623cc64 100644 --- a/backend/internal/handler/openai_gateway_handler_test.go +++ b/backend/internal/handler/openai_gateway_handler_test.go @@ -15,6 +15,7 @@ import ( "time" "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey" pkghttputil "github.com/Wei-Shaw/sub2api/internal/pkg/httputil" "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" "github.com/Wei-Shaw/sub2api/internal/pkg/xai" @@ -1948,6 +1949,12 @@ type openAIResponsesWSUsageLogCase struct { afterFirstUpstreamRequest func(channelSvc *service.ChannelService) error // group 覆盖 apiKey.Group(分组级模型白名单测试用);nil 保持原有无分组行为。 group *service.Group + // apiKeyService 非 nil 时模拟 API Key 认证中间件:连接认证快照经它按 + // apiKeyCredential 取得,并把其分组放入请求 ctx;handler 也使用它。 + apiKeyService *service.APIKeyService + apiKeyCredential string + // accountRateMultiplier 覆盖账号倍率(利润门测试用)。 + accountRateMultiplier *float64 // firstFrameCloseExpected:首帧即被拒(连接被 1008 关闭),不期待任何响应帧。 firstFrameCloseExpected bool // secondTurnCloseExpected:第二个 turn 被拒(连接被 1008 关闭)。 @@ -2962,6 +2969,7 @@ func runOpenAIResponsesWebSocketUsageLogCase(t *testing.T, tc openAIResponsesWSU if tc.accountPlatform != "" { account.Platform = tc.accountPlatform } + account.RateMultiplier = tc.accountRateMultiplier if strings.TrimSpace(tc.ingressMode) != "" { account.Extra["openai_apikey_responses_websockets_v2_mode"] = tc.ingressMode } @@ -3050,6 +3058,12 @@ func runOpenAIResponsesWebSocketUsageLogCase(t *testing.T, tc openAIResponsesWSU GroupID: &groupID, User: &service.User{ID: 1701, Status: service.StatusActive}, } + if tc.apiKeyService != nil { + h.apiKeyService = tc.apiKeyService + authKey, err := tc.apiKeyService.GetByKey(context.Background(), tc.apiKeyCredential) + require.NoError(t, err) + apiKey = authKey + } if tc.simpleModeRejectAtRead > 0 { apiKey.RateLimit5h = 1 } @@ -3060,6 +3074,9 @@ func runOpenAIResponsesWebSocketUsageLogCase(t *testing.T, tc openAIResponsesWSU router.Use(func(c *gin.Context) { c.Set(string(middleware.ContextKeyAPIKey), apiKey) c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{UserID: apiKey.User.ID, Concurrency: 1}) + if tc.apiKeyService != nil && apiKey.Group != nil { + c.Request = c.Request.WithContext(context.WithValue(c.Request.Context(), ctxkey.Group, apiKey.Group)) + } c.Next() }) router.GET("/openai/v1/responses", h.ResponsesWebSocket) diff --git a/backend/internal/handler/openai_gateway_ws_group_pricing_test.go b/backend/internal/handler/openai_gateway_ws_group_pricing_test.go new file mode 100644 index 000000000000..7b1a581532f6 --- /dev/null +++ b/backend/internal/handler/openai_gateway_ws_group_pricing_test.go @@ -0,0 +1,293 @@ +package handler + +import ( + "context" + "errors" + "sync" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/service" + coderws "github.com/coder/websocket" + "github.com/stretchr/testify/require" +) + +// 分组调价对已打开的 WebSocket 连接生效:同一条连接的两个 turn 之间由管理端 +// 改分组倍率(并按分组失效认证缓存),第二个 turn 必须按新倍率计费、按新售价 +// 过利润门;HTTP 请求每次经认证缓存取快照,本来就是这个行为。 + +const ( + wsGroupPricingGroupID = int64(4201) // 与 runOpenAIResponsesWebSocketUsageLogCase 的调度分组一致 + wsGroupPricingKey = "sk-ws-group-pricing" + wsGroupPricingFirst = `{"type":"response.create","model":"gpt-5.6-sol","stream":false}` + wsGroupPricingSecond = `{"type":"response.create","model":"gpt-5.6-sol","stream":false}` +) + +// wsGroupPricingAPIKeyRepoStub 只实现认证查询与按分组列 Key(认证缓存失效用)。 +type wsGroupPricingAPIKeyRepoStub struct { + service.APIKeyRepository + mu sync.Mutex + apiKey service.APIKey + group service.Group + err error + lookups int +} + +func newWSGroupPricingAPIKeyRepoStub(rate float64) *wsGroupPricingAPIKeyRepoStub { + groupID := wsGroupPricingGroupID + return &wsGroupPricingAPIKeyRepoStub{ + apiKey: service.APIKey{ + ID: 1801, + UserID: 1701, + Key: wsGroupPricingKey, + Status: service.StatusActive, + GroupID: &groupID, + User: &service.User{ID: 1701, Status: service.StatusActive, Concurrency: 1}, + }, + group: service.Group{ + ID: groupID, + Name: "ws-group-pricing", + Platform: service.PlatformOpenAI, + Status: service.StatusActive, + RateMultiplier: rate, + Hydrated: true, + }, + } +} + +func (s *wsGroupPricingAPIKeyRepoStub) GetByKeyForAuth(ctx context.Context, key string) (*service.APIKey, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.lookups++ + if s.err != nil { + return nil, s.err + } + if key != s.apiKey.Key { + return nil, service.ErrAPIKeyNotFound + } + apiKey := s.apiKey + user := *s.apiKey.User + apiKey.User = &user + group := s.group + apiKey.Group = &group + return &apiKey, nil +} + +func (s *wsGroupPricingAPIKeyRepoStub) ListKeysByGroupID(ctx context.Context, groupID int64) ([]string, error) { + return []string{s.apiKey.Key}, nil +} + +// adminUpdate 模拟管理端改 Key/分组后按分组失效认证缓存(与 admin 分组更新同一入口)。 +func (s *wsGroupPricingAPIKeyRepoStub) adminUpdate(apiKeyService *service.APIKeyService, fn func(apiKey *service.APIKey, group *service.Group)) error { + s.mu.Lock() + fn(&s.apiKey, &s.group) + s.mu.Unlock() + apiKeyService.InvalidateAuthCacheByGroupID(context.Background(), wsGroupPricingGroupID) + return nil +} + +func (s *wsGroupPricingAPIKeyRepoStub) lookupCount() int { + s.mu.Lock() + defer s.mu.Unlock() + return s.lookups +} + +func newWSGroupPricingAPIKeyService(repo *wsGroupPricingAPIKeyRepoStub) *service.APIKeyService { + return service.NewAPIKeyService(repo, nil, nil, nil, nil, nil, &config.Config{}) +} + +func TestOpenAIResponsesWebSocket_GroupRateChangeAppliesToNextTurn(t *testing.T) { + for _, mode := range []string{service.OpenAIWSIngressModePassthrough, service.OpenAIWSIngressModeCtxPool} { + t.Run(mode, func(t *testing.T) { + repo := newWSGroupPricingAPIKeyRepoStub(3.0) + apiKeyService := newWSGroupPricingAPIKeyService(repo) + got := runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{ + firstPayload: wsGroupPricingFirst, + secondPayload: wsGroupPricingSecond, + ingressMode: mode, + apiKeyService: apiKeyService, + apiKeyCredential: wsGroupPricingKey, + afterFirstUpstreamRequest: func(*service.ChannelService) error { + return repo.adminUpdate(apiKeyService, func(_ *service.APIKey, group *service.Group) { group.RateMultiplier = 0.3 }) + }, + }) + + require.Len(t, got.logs, 2) + require.InDelta(t, 3.0, got.logs[0].RateMultiplier, 1e-12, "turn 1 bills at the rate in force at connect") + require.InDelta(t, 0.3, got.logs[1].RateMultiplier, 1e-12, "turn 2 must bill at the group rate in force when it starts") + require.Greater(t, got.logs[0].ActualCost, 0.0) + require.InDelta(t, got.logs[0].ActualCost/10, got.logs[1].ActualCost, 1e-15) + }) + } +} + +func TestOpenAIResponsesWebSocket_GroupRateChangeReachesProfitGateOnNextTurn(t *testing.T) { + repo := newWSGroupPricingAPIKeyRepoStub(3.0) + repo.group.ProfitControlEnabled = true + repo.group.ProfitMinMargin = 0.1 + apiKeyService := newWSGroupPricingAPIKeyService(repo) + accountRate := 1.0 + runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{ + firstPayload: wsGroupPricingFirst, + secondPayload: wsGroupPricingSecond, + apiKeyService: apiKeyService, + apiKeyCredential: wsGroupPricingKey, + accountRateMultiplier: &accountRate, + // 3.0 → 0.3:账号倍率 1.0 在新售价下已越线,第二个 turn 必须在转发上游前被拒。 + afterFirstUpstreamRequest: func(*service.ChannelService) error { + return repo.adminUpdate(apiKeyService, func(_ *service.APIKey, group *service.Group) { group.RateMultiplier = 0.3 }) + }, + secondTurnCloseExpected: true, + closeStatus: coderws.StatusTryAgainLater, + closeReason: "no longer eligible for this connection", + }) +} + +func TestOpenAIResponsesWebSocket_KeyMovedToAnotherGroupKeepsConnectionGroup(t *testing.T) { + repo := newWSGroupPricingAPIKeyRepoStub(3.0) + apiKeyService := newWSGroupPricingAPIKeyService(repo) + got := runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{ + firstPayload: wsGroupPricingFirst, + secondPayload: wsGroupPricingSecond, + apiKeyService: apiKeyService, + apiKeyCredential: wsGroupPricingKey, + afterFirstUpstreamRequest: func(*service.ChannelService) error { + return repo.adminUpdate(apiKeyService, func(apiKey *service.APIKey, group *service.Group) { + other := wsGroupPricingGroupID + 1 + apiKey.GroupID = &other + group.ID = other + group.RateMultiplier = 0.3 + }) + }, + }) + + require.Len(t, got.logs, 2) + require.InDelta(t, 3.0, got.logs[1].RateMultiplier, 1e-12, + "a key moved to another group keeps the connection's group: the connection was scheduled from it") +} + +func TestOpenAIResponsesWebSocket_FailedKeyRefreshKeepsConnectionGroup(t *testing.T) { + repo := newWSGroupPricingAPIKeyRepoStub(3.0) + apiKeyService := newWSGroupPricingAPIKeyService(repo) + got := runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{ + firstPayload: wsGroupPricingFirst, + secondPayload: wsGroupPricingSecond, + apiKeyService: apiKeyService, + apiKeyCredential: wsGroupPricingKey, + afterFirstUpstreamRequest: func(*service.ChannelService) error { + return repo.adminUpdate(apiKeyService, func(_ *service.APIKey, group *service.Group) { + group.RateMultiplier = 0.3 + repo.err = errors.New("auth store unavailable") + }) + }, + }) + + require.Len(t, got.logs, 2) + require.InDelta(t, 3.0, got.logs[1].RateMultiplier, 1e-12, "a failed refresh keeps the connection snapshot") + require.GreaterOrEqual(t, repo.lookupCount(), 2, "the second turn must have attempted a refresh") +} + +type wsTurnAPIKeyLookupFunc func(ctx context.Context, key string) (*service.APIKey, error) + +func (f wsTurnAPIKeyLookupFunc) GetByKey(ctx context.Context, key string) (*service.APIKey, error) { + return f(ctx, key) +} + +func TestRefreshOpenAIWSTurnBillingAPIKey(t *testing.T) { + groupID := int64(77) + conn := &service.APIKey{ + ID: 5, + Key: "sk-conn", + GroupID: &groupID, + User: &service.User{ID: 9}, + Group: &service.Group{ID: groupID, Platform: service.PlatformOpenAI, SubscriptionType: service.SubscriptionTypeStandard, RateMultiplier: 3}, + } + latestWith := func(mutate func(k *service.APIKey)) *service.APIKey { + gid := groupID + k := &service.APIKey{ + ID: 5, + Key: "sk-conn", + GroupID: &gid, + User: &service.User{ID: 9, Balance: 123}, + Group: &service.Group{ID: groupID, Platform: service.PlatformOpenAI, SubscriptionType: service.SubscriptionTypeStandard, RateMultiplier: 0.3}, + } + if mutate != nil { + mutate(k) + } + return k + } + + t.Run("same group adopts the latest group and nothing else", func(t *testing.T) { + got := refreshOpenAIWSTurnBillingAPIKey(context.Background(), wsTurnAPIKeyLookupFunc(func(ctx context.Context, key string) (*service.APIKey, error) { + require.Equal(t, "sk-conn", key) + return latestWith(nil), nil + }), conn) + require.NotSame(t, conn, got) + require.InDelta(t, 0.3, got.Group.RateMultiplier, 1e-12) + require.Same(t, conn.User, got.User, "only the group snapshot is refreshed") + require.Equal(t, conn.ID, got.ID) + require.InDelta(t, 3.0, conn.Group.RateMultiplier, 1e-12, "the connection snapshot is never mutated") + }) + + keep := map[string]wsTurnAPIKeyLookupFunc{ + "lookup error": func(ctx context.Context, key string) (*service.APIKey, error) { + return nil, errors.New("boom") + }, + "not found": func(ctx context.Context, key string) (*service.APIKey, error) { + return nil, service.ErrAPIKeyNotFound + }, + "nil key": func(ctx context.Context, key string) (*service.APIKey, error) { return nil, nil }, + "different key id": func(ctx context.Context, key string) (*service.APIKey, error) { + return latestWith(func(k *service.APIKey) { k.ID = 6 }), nil + }, + "moved group": func(ctx context.Context, key string) (*service.APIKey, error) { + return latestWith(func(k *service.APIKey) { other := int64(78); k.GroupID = &other; k.Group.ID = other }), nil + }, + "no group": func(ctx context.Context, key string) (*service.APIKey, error) { + return latestWith(func(k *service.APIKey) { k.Group = nil }), nil + }, + "platform changed": func(ctx context.Context, key string) (*service.APIKey, error) { + return latestWith(func(k *service.APIKey) { k.Group.Platform = service.PlatformAnthropic }), nil + }, + "billing type changed": func(ctx context.Context, key string) (*service.APIKey, error) { + return latestWith(func(k *service.APIKey) { k.Group.SubscriptionType = service.SubscriptionTypeSubscription }), nil + }, + } + for name, lookup := range keep { + t.Run(name+" keeps the connection snapshot", func(t *testing.T) { + require.Same(t, conn, refreshOpenAIWSTurnBillingAPIKey(context.Background(), lookup, conn)) + }) + } + + t.Run("no credential or no group skips the lookup", func(t *testing.T) { + called := false + lookup := wsTurnAPIKeyLookupFunc(func(ctx context.Context, key string) (*service.APIKey, error) { + called = true + return latestWith(nil), nil + }) + noKey := *conn + noKey.Key = "" + require.Same(t, &noKey, refreshOpenAIWSTurnBillingAPIKey(context.Background(), lookup, &noKey)) + noGroup := *conn + noGroup.Group = nil + require.Same(t, &noGroup, refreshOpenAIWSTurnBillingAPIKey(context.Background(), lookup, &noGroup)) + require.Nil(t, refreshOpenAIWSTurnBillingAPIKey(context.Background(), lookup, nil)) + require.Same(t, conn, refreshOpenAIWSTurnBillingAPIKey(context.Background(), nil, conn)) + require.False(t, called) + }) +} + +func TestOpenAIWSTurnBillingAPIKeysKeepPreviousTurnUntilItIsRecorded(t *testing.T) { + conn := &service.APIKey{ID: 1} + turn2 := &service.APIKey{ID: 2} + turn3 := &service.APIKey{ID: 3} + var keys openAIWSTurnBillingAPIKeys + require.Same(t, conn, keys.forTurn(1, conn), "a turn without BeforeTurn bills with the connection snapshot") + keys.set(2, turn2) + keys.set(3, turn3) // turn 3 starts before turn 2's usage is submitted + require.Same(t, turn2, keys.forTurn(2, conn)) + require.Same(t, turn3, keys.forTurn(3, conn)) + keys.set(4, conn) + require.Same(t, conn, keys.forTurn(2, conn), "only the current and previous turn are kept") + require.Len(t, keys.keys, 2) +} From 71e97d1353a1cdb788c82a0e8b9020e4e1e0e614 Mon Sep 17 00:00:00 2001 From: lyy0709 <65712338+lyy0709@users.noreply.github.com> Date: Thu, 1 Oct 2026 03:30:56 -0700 Subject: [PATCH 20/88] fix(models): enforce account mappings for remote Codex discovery --- .../openai_codex_models_handler_test.go | 29 +++++++++++++++++++ .../service/openai_codex_models_pinned.go | 11 +++++++ .../openai_codex_models_pinned_test.go | 10 +++++-- .../internal/service/openai_models_list.go | 21 ++++++++++---- .../service/openai_models_list_test.go | 16 ++++++++++ 5 files changed, 80 insertions(+), 7 deletions(-) diff --git a/backend/internal/handler/openai_codex_models_handler_test.go b/backend/internal/handler/openai_codex_models_handler_test.go index ea3154563105..5d102cfbf95c 100644 --- a/backend/internal/handler/openai_codex_models_handler_test.go +++ b/backend/internal/handler/openai_codex_models_handler_test.go @@ -1001,3 +1001,32 @@ func TestCodexModelsPinnedAccountsETagMatchReturns304(t *testing.T) { require.Equal(t, http.StatusNotModified, second.Code, second.Body.String()) require.Empty(t, second.Body.Bytes()) } + +func TestCodexModelsDiscoveryProjectsWildcardMappingWithoutAllowlist(t *testing.T) { + for _, mode := range []string{"ordinary", "pinned", "fallback"} { + t.Run(mode, func(t *testing.T) { + account := newPinnedCodexAccount(1, service.StatusActive, true, false) + account.Credentials["model_mapping"] = map[string]any{"gpt-*": "gpt-5.4"} + upstream := &codexModelsPinnedHTTPUpstream{bodies: map[int64]string{ + 1: `{"models":[{"slug":"gpt-5.4"},{"slug":"blocked"},{"slug":"codex-auto-review"},{"slug":"gpt-image-2"}]}`, + }} + handler := newPinnedCodexTestHandler([]service.Account{account}, upstream, 3) + group := &service.Group{ID: 84, Platform: service.PlatformOpenAI, + ModelAllowlist: service.GroupModelAllowlist{Models: []string{"blocked"}}, + } + if mode != "ordinary" { + group.CodexModelsManifestConfig = service.GroupCodexModelsManifestConfig{Enabled: true, AccountIDs: []int64{1}} + } + if mode == "fallback" { + group.CodexModelsManifestConfig.AccountIDs = []int64{999} + group.CodexModelsManifestConfig.FallbackToScheduler = true + } + first := performCodexModelsRequestForGroup(t, handler, group, "") + require.Equal(t, http.StatusOK, first.Code, first.Body.String()) + require.Equal(t, []string{"gpt-5.4"}, codexHandlerManifestSlugs(t, first)) + require.NotEmpty(t, first.Header().Get("ETag")) + second := performCodexModelsRequestForGroup(t, handler, group, first.Header().Get("ETag")) + require.Equal(t, http.StatusNotModified, second.Code) + }) + } +} diff --git a/backend/internal/service/openai_codex_models_pinned.go b/backend/internal/service/openai_codex_models_pinned.go index b74b3eafdc45..16faefd0680f 100644 --- a/backend/internal/service/openai_codex_models_pinned.go +++ b/backend/internal/service/openai_codex_models_pinned.go @@ -8,6 +8,7 @@ import ( "fmt" "log/slog" "net/http" + "slices" "strings" "sync" "time" @@ -91,6 +92,13 @@ func mergeCodexModelsManifestBodies(bodies [][]byte) ([]byte, error) { } } + if len(bodies) == 1 { + _, original, err := modelCatalogEntries(bodies[0], "models") + if err == nil && slices.EqualFunc(original, mergedModels, func(a, b json.RawMessage) bool { return bytes.Equal(a, b) }) { + return bodies[0], nil + } + } + encodedModels, err := json.Marshal(mergedModels) if err != nil { return nil, fmt.Errorf("encode merged codex models: %w", err) @@ -139,6 +147,9 @@ func (s *OpenAIGatewayService) FetchPinnedCodexModelsManifest(ctx context.Contex if err != nil { return nil, nil, fmt.Errorf("merge pinned codex models manifests: %w", err) } + if len(results) == 1 && bytes.Equal(merged, results[0].response.Body) { + return results[0].response, results[0].account, nil + } return &OpenAIModelsResponse{Body: merged, ETag: codexModelsManifestBodyETag(merged)}, results[0].account, nil } diff --git a/backend/internal/service/openai_codex_models_pinned_test.go b/backend/internal/service/openai_codex_models_pinned_test.go index eee3100c7636..3c389402f47c 100644 --- a/backend/internal/service/openai_codex_models_pinned_test.go +++ b/backend/internal/service/openai_codex_models_pinned_test.go @@ -63,11 +63,11 @@ func TestMergeCodexModelsManifestBodiesHandlesSluglessEntries(t *testing.T) { } func TestMergeCodexModelsManifestBodiesSingleBody(t *testing.T) { - body := `{"models":[{"slug":"model-a"}]}` + body := `{ "unknown": true, "models": [ { "slug": "model-a" } ] }` merged, err := mergeCodexModelsManifestBodies([][]byte{[]byte(body)}) require.NoError(t, err) - require.JSONEq(t, body, string(merged)) + require.Equal(t, body, string(merged)) } func TestMergeCodexModelsManifestBodiesRejectsInvalidInput(t *testing.T) { @@ -80,3 +80,9 @@ func TestMergeCodexModelsManifestBodiesRejectsInvalidInput(t *testing.T) { _, err = mergeCodexModelsManifestBodies([][]byte{[]byte(`{}`), []byte(`{"models":{}`)}) require.Error(t, err, "models 非数组必须报错") } + +func TestMergeCodexModelsManifestBodiesSingleBodyDeduplicates(t *testing.T) { + merged, err := mergeCodexModelsManifestBodies([][]byte{[]byte(`{"models":[{"slug":"a"},{"slug":"a","extra":true}]}`)}) + require.NoError(t, err) + require.JSONEq(t, `{"models":[{"slug":"a"}]}`, string(merged)) +} diff --git a/backend/internal/service/openai_models_list.go b/backend/internal/service/openai_models_list.go index 2a07f10d67af..9b27a03cd4e1 100644 --- a/backend/internal/service/openai_models_list.go +++ b/backend/internal/service/openai_models_list.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" "net/http" + "slices" "sort" "strings" @@ -229,6 +230,10 @@ func projectAccountModelsBody(body []byte, account *Account, group *Group, codex continue } seen[id] = struct{}{} + if id == target { + projected = append(projected, raw) + continue + } var entry map[string]json.RawMessage if err := json.Unmarshal(raw, &entry); err != nil { return nil, err @@ -243,6 +248,9 @@ func projectAccountModelsBody(body []byte, account *Account, group *Group, codex } projected = append(projected, encoded) } + if slices.EqualFunc(entries, projected, func(a, b json.RawMessage) bool { return bytes.Equal(a, b) }) { + return body, nil + } envelope[field], err = json.Marshal(projected) if err != nil { return nil, err @@ -250,18 +258,21 @@ func projectAccountModelsBody(body []byte, account *Account, group *Group, codex return json.Marshal(envelope) } -// ApplyPinnedCodexModelsMapping is used by pinned discovery and its scheduler -// fallback. The ordinary (non-pinned) Codex path retains its local catalog policy. +// ApplyPinnedCodexModelsMapping projects all remotely discovered Codex catalogs, +// including ordinary discovery and pinned scheduler fallback. Locally generated +// catalogs retain their existing policy and do not pass through this function. func ApplyPinnedCodexModelsMapping(response *OpenAIModelsResponse, account *Account, group *Group) error { - if group == nil || group.Platform != PlatformOpenAI || !group.CodexModelsManifestConfig.Enabled { + if group == nil || group.Platform != PlatformOpenAI { return nil } body, err := projectAccountModelsBody(response.Body, account, group, true) if err != nil { return err } - response.Body = body - response.ETag = codexModelsManifestBodyETag(body) + if !bytes.Equal(response.Body, body) { + response.Body = body + response.ETag = codexModelsManifestBodyETag(body) + } return nil } diff --git a/backend/internal/service/openai_models_list_test.go b/backend/internal/service/openai_models_list_test.go index 08df6e249c90..edb50a569e50 100644 --- a/backend/internal/service/openai_models_list_test.go +++ b/backend/internal/service/openai_models_list_test.go @@ -3,6 +3,7 @@ package service import ( "context" "encoding/json" + "fmt" "io" "net/http" "strings" @@ -346,3 +347,18 @@ func TestOpenAIModelsCacheSeparatesRepresentationsForIdenticalRequests(t *testin require.JSONEq(t, manifestBody, string(manifest.Body)) require.EqualValues(t, 2, calls.Load()) } + +func TestApplyCodexModelsMappingPreservesUnchangedBodyAndETag(t *testing.T) { + for _, pinned := range []bool{false, true} { + t.Run(fmt.Sprint(pinned), func(t *testing.T) { + account := newCodexModelsAPIKeyTestAccount("https://models.example/v1") + account.Credentials["model_mapping"] = map[string]any{"gpt-5.4": "gpt-5.4"} + body := []byte(`{ "unknown": { "keep": true }, "models": [ { "slug": "gpt-5.4", "custom": 123 } ] }`) + response := &OpenAIModelsResponse{Body: body, ETag: `W/"upstream-validator"`} + group := &Group{Platform: PlatformOpenAI, CodexModelsManifestConfig: GroupCodexModelsManifestConfig{Enabled: pinned}} + require.NoError(t, ApplyPinnedCodexModelsMapping(response, account, group)) + require.Equal(t, body, response.Body) + require.Equal(t, `W/"upstream-validator"`, response.ETag) + }) + } +} From f3d752693f26cc4565de196c1305ec467187b9e5 Mon Sep 17 00:00:00 2001 From: SPHINX998 <51872346+SPHINX998@users.noreply.github.com> Date: Thu, 1 Oct 2026 21:51:37 +0800 Subject: [PATCH 21/88] fix(apicompat): flatten named chat tool_choice for Responses Chat Completions names a forced function as {"type":"function","function":{"name":"X"}}, while the Responses API expects {"type":"function","name":"X"}. ChatCompletionsToResponses passed the Chat shape through unchanged, so OpenAI API-key accounts on /v1/chat/completions forwarded the nested shape to /v1/responses and the upstream rejected the request. Flatten the named Chat choice; strings and objects already in the Responses shape are kept as-is. Co-Authored-By: Claude Opus 5.5 --- .../apicompat/chatcompletions_to_responses.go | 33 +++++++++++++- ...mpletions_to_responses_tool_choice_test.go | 43 +++++++++++++++++++ 2 files changed, 74 insertions(+), 2 deletions(-) create mode 100644 backend/internal/pkg/apicompat/chatcompletions_to_responses_tool_choice_test.go diff --git a/backend/internal/pkg/apicompat/chatcompletions_to_responses.go b/backend/internal/pkg/apicompat/chatcompletions_to_responses.go index d1f3bccc008f..46cfda43b30d 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_to_responses.go +++ b/backend/internal/pkg/apicompat/chatcompletions_to_responses.go @@ -88,10 +88,11 @@ func ChatCompletionsToResponses(req *ChatCompletionsRequest) (*ResponsesRequest, out.Tools = convertChatToolsToResponses(req.Tools, req.Functions) } - // tool_choice: already compatible format — pass through directly. + // tool_choice: strings and Responses-shaped objects pass through; a named + // Chat choice nests the name under "function" and must be flattened. // Legacy function_call needs mapping. if len(req.ToolChoice) > 0 { - out.ToolChoice = req.ToolChoice + out.ToolChoice = convertChatToolChoiceToResponses(req.ToolChoice) } else if len(req.FunctionCall) > 0 { tc, err := convertChatFunctionCallToToolChoice(req.FunctionCall) if err != nil { @@ -490,6 +491,34 @@ func defaultStrictFalse(src *bool) *bool { return src } +// convertChatToolChoiceToResponses maps a Chat Completions tool_choice to the +// Responses API shape. +// +// {"type":"function","function":{"name":"X"}} → {"type":"function","name":"X"} +// +// Strings ("auto", "none", "required") and objects that already use the +// Responses shape are returned unchanged. +func convertChatToolChoiceToResponses(raw json.RawMessage) json.RawMessage { + var choice struct { + Type string `json:"type"` + Function *struct { + Name string `json:"name"` + } `json:"function"` + } + if err := json.Unmarshal(raw, &choice); err != nil || choice.Type != "function" || choice.Function == nil { + return raw + } + name := strings.TrimSpace(choice.Function.Name) + if name == "" { + return raw + } + flat, err := json.Marshal(map[string]string{"type": "function", "name": name}) + if err != nil { + return raw + } + return flat +} + // convertChatFunctionCallToToolChoice maps the legacy function_call field to a // Responses API tool_choice value. // diff --git a/backend/internal/pkg/apicompat/chatcompletions_to_responses_tool_choice_test.go b/backend/internal/pkg/apicompat/chatcompletions_to_responses_tool_choice_test.go new file mode 100644 index 000000000000..736a0b5acf67 --- /dev/null +++ b/backend/internal/pkg/apicompat/chatcompletions_to_responses_tool_choice_test.go @@ -0,0 +1,43 @@ +package apicompat + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +// Chat Completions nests a named choice under "function"; the Responses API +// expects the name at the top level and rejects the nested shape. +func TestChatCompletionsToResponses_ToolChoiceShape(t *testing.T) { + tests := []struct { + name string + choice string + want string + }{ + {name: "named chat function is flattened", choice: `{"type":"function","function":{"name":"get_weather"}}`, want: `{"type":"function","name":"get_weather"}`}, + {name: "flat responses function is kept", choice: `{"type":"function","name":"get_weather"}`, want: `{"type":"function","name":"get_weather"}`}, + {name: "auto string is kept", choice: `"auto"`, want: `"auto"`}, + {name: "required string is kept", choice: `"required"`, want: `"required"`}, + {name: "none string is kept", choice: `"none"`, want: `"none"`}, + {name: "allowed_tools policy is kept", choice: `{"type":"allowed_tools","mode":"auto","tools":[{"type":"function","name":"get_weather"}]}`, want: `{"type":"allowed_tools","mode":"auto","tools":[{"type":"function","name":"get_weather"}]}`}, + {name: "function without name is kept", choice: `{"type":"function","function":{}}`, want: `{"type":"function","function":{}}`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := &ChatCompletionsRequest{ + Model: "gpt-4o", + Messages: []ChatMessage{{Role: "user", Content: json.RawMessage(`"weather?"`)}}, + Tools: []ChatTool{{ + Type: "function", + Function: &ChatFunction{Name: "get_weather", Parameters: json.RawMessage(`{"type":"object"}`)}, + }}, + ToolChoice: json.RawMessage(tt.choice), + } + + resp, err := ChatCompletionsToResponses(req) + require.NoError(t, err) + require.JSONEq(t, tt.want, string(resp.ToolChoice)) + }) + } +} From 9a8ab4f88e7318a830be7ad0c299862798bb8d70 Mon Sep 17 00:00:00 2001 From: SPHINX998 <51872346+SPHINX998@users.noreply.github.com> Date: Thu, 1 Oct 2026 21:53:30 +0800 Subject: [PATCH 22/88] fix(apicompat): keep chat developer messages as developer role chatMessageToResponsesItems only mapped "system" explicitly and sent every other unknown role through the user converter, so Chat Completions developer messages reached the Responses upstream as user turns and lost their instruction priority. The Responses API accepts role "developer" in input messages; convert it like a system message and keep the original role. Downstream converters already understand it (ResponsesToAnthropicRequest folds developer into the system prompt, the Codex transform forwards it unchanged). Co-Authored-By: Claude Opus 5.5 --- .../apicompat/chatcompletions_to_responses.go | 8 ++-- ...etions_to_responses_developer_role_test.go | 43 +++++++++++++++++++ 2 files changed, 48 insertions(+), 3 deletions(-) create mode 100644 backend/internal/pkg/apicompat/chatcompletions_to_responses_developer_role_test.go diff --git a/backend/internal/pkg/apicompat/chatcompletions_to_responses.go b/backend/internal/pkg/apicompat/chatcompletions_to_responses.go index d1f3bccc008f..4cb982a8fc8e 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_to_responses.go +++ b/backend/internal/pkg/apicompat/chatcompletions_to_responses.go @@ -121,7 +121,7 @@ func convertChatMessagesToResponsesInput(msgs []ChatMessage) ([]ResponsesInputIt // ResponsesInputItem values. func chatMessageToResponsesItems(m ChatMessage) ([]ResponsesInputItem, error) { switch m.Role { - case "system": + case "system", "developer": return chatSystemToResponses(m) case "user": return chatUserToResponses(m) @@ -136,7 +136,9 @@ func chatMessageToResponsesItems(m ChatMessage) ([]ResponsesInputItem, error) { } } -// chatSystemToResponses converts a system message. +// chatSystemToResponses converts a system or developer message, keeping its +// role: the Responses API accepts both, and developer must not be demoted to +// a user turn. func chatSystemToResponses(m ChatMessage) ([]ResponsesInputItem, error) { parsed, err := parseChatMessageContent(m.Content) if err != nil { @@ -146,7 +148,7 @@ func chatSystemToResponses(m ChatMessage) ([]ResponsesInputItem, error) { if err != nil { return nil, err } - return []ResponsesInputItem{{Type: "message", Role: "system", Content: content}}, nil + return []ResponsesInputItem{{Type: "message", Role: m.Role, Content: content}}, nil } // chatUserToResponses converts a user message, handling both plain strings and diff --git a/backend/internal/pkg/apicompat/chatcompletions_to_responses_developer_role_test.go b/backend/internal/pkg/apicompat/chatcompletions_to_responses_developer_role_test.go new file mode 100644 index 000000000000..d6fbcad6cc92 --- /dev/null +++ b/backend/internal/pkg/apicompat/chatcompletions_to_responses_developer_role_test.go @@ -0,0 +1,43 @@ +package apicompat + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +// The Responses API accepts role "developer" in input messages. Converting it +// to "user" silently demotes the caller's instructions to user turns. +func TestChatCompletionsToResponses_DeveloperRolePreserved(t *testing.T) { + tests := []struct { + name string + content string + wantContent string + }{ + {name: "string content", content: `"Answer in French."`, wantContent: `"Answer in French."`}, + {name: "text parts", content: `[{"type":"text","text":"Answer in French."}]`, wantContent: `[{"type":"input_text","text":"Answer in French."}]`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := &ChatCompletionsRequest{ + Model: "gpt-4o", + Messages: []ChatMessage{ + {Role: "developer", Content: json.RawMessage(tt.content)}, + {Role: "user", Content: json.RawMessage(`"Hello"`)}, + }, + } + + resp, err := ChatCompletionsToResponses(req) + require.NoError(t, err) + + var items []ResponsesInputItem + require.NoError(t, json.Unmarshal(resp.Input, &items)) + require.Len(t, items, 2) + require.Equal(t, "message", items[0].Type) + require.Equal(t, "developer", items[0].Role) + require.JSONEq(t, tt.wantContent, string(items[0].Content)) + require.Equal(t, "user", items[1].Role) + }) + } +} From 7a767d5ce874d155ab644d5965d50dff80f3b5df Mon Sep 17 00:00:00 2001 From: SPHINX998 <51872346+SPHINX998@users.noreply.github.com> Date: Thu, 1 Oct 2026 21:55:31 +0800 Subject: [PATCH 23/88] fix(apicompat): pair legacy chat function calls with their results Legacy Chat function calling has no call IDs: the assistant turn carries function_call and the role=function result refers to it only by name. ChatCompletionsToResponses ignored the assistant function_call and used the function name as the result's call_id, so the Responses upstream got a function_call_output with no matching function_call and rejected the replayed history. Emit a function_call item for each legacy assistant call and give the following same-name result the same call_id. IDs follow message order (call_legacy_N), so the same history converts to the same input across turns, and skip IDs already used by tool_calls. A result with no preceding call keeps the previous name-based call_id. Co-Authored-By: Claude Opus 5.5 --- .../apicompat/chatcompletions_to_responses.go | 74 +++++++++++++++- ..._to_responses_legacy_function_call_test.go | 87 +++++++++++++++++++ 2 files changed, 158 insertions(+), 3 deletions(-) create mode 100644 backend/internal/pkg/apicompat/chatcompletions_to_responses_legacy_function_call_test.go diff --git a/backend/internal/pkg/apicompat/chatcompletions_to_responses.go b/backend/internal/pkg/apicompat/chatcompletions_to_responses.go index d1f3bccc008f..f6585ac3db25 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_to_responses.go +++ b/backend/internal/pkg/apicompat/chatcompletions_to_responses.go @@ -107,7 +107,18 @@ func ChatCompletionsToResponses(req *ChatCompletionsRequest) (*ResponsesRequest, // array into a Responses API input items array. func convertChatMessagesToResponsesInput(msgs []ChatMessage) ([]ResponsesInputItem, error) { var out []ResponsesInputItem + legacyIDs := legacyFunctionCallIDs{msgs: msgs} for _, m := range msgs { + switch { + case m.Role == "assistant" && m.FunctionCall != nil && len(m.ToolCalls) == 0: + m.ToolCalls = []ChatToolCall{{ + ID: legacyIDs.assign(m.FunctionCall.Name), + Type: "function", + Function: *m.FunctionCall, + }} + case m.Role == "function" && m.ToolCallID == "": + m.ToolCallID = legacyIDs.claim(m.Name) + } items, err := chatMessageToResponsesItems(m) if err != nil { return nil, err @@ -117,6 +128,58 @@ func convertChatMessagesToResponsesInput(msgs []ChatMessage) ([]ResponsesInputIt return out, nil } +// legacyFunctionCallIDs pairs legacy function calls with their results. Legacy +// function calling carries no call IDs: the assistant turn has function_call +// and the role=function result refers to it only by name, while the Responses +// API pairs function_call and function_call_output by call_id. IDs follow +// message order, so a replayed history converts to the same input, and skip +// IDs already used by tool_calls in the same conversation. +type legacyFunctionCallIDs struct { + msgs []ChatMessage + used map[string]bool + next int + pending map[string][]string +} + +// assign returns a new call ID for a legacy function call and queues it for +// the next result with the same function name. +func (ids *legacyFunctionCallIDs) assign(name string) string { + if ids.used == nil { + ids.used = make(map[string]bool) + ids.pending = make(map[string][]string) + for _, m := range ids.msgs { + for _, tc := range m.ToolCalls { + ids.used[tc.ID] = true + } + if m.ToolCallID != "" { + ids.used[m.ToolCallID] = true + } + } + } + var id string + for { + ids.next++ + id = fmt.Sprintf("call_legacy_%d", ids.next) + if !ids.used[id] { + break + } + } + ids.used[id] = true + ids.pending[name] = append(ids.pending[name], id) + return id +} + +// claim returns the oldest unanswered call ID for name, or "" when no legacy +// call with that name precedes the result. +func (ids *legacyFunctionCallIDs) claim(name string) string { + queue := ids.pending[name] + if len(queue) == 0 { + return "" + } + ids.pending[name] = queue[1:] + return queue[0] +} + // chatMessageToResponsesItems converts a single ChatMessage into one or more // ResponsesInputItem values. func chatMessageToResponsesItems(m ChatMessage) ([]ResponsesInputItem, error) { @@ -303,8 +366,9 @@ func chatToolToResponses(m ChatMessage) ([]ResponsesInputItem, error) { } // chatFunctionToResponses converts a legacy function result message -// (role=function) into a function_call_output item. The Name field is used as -// call_id since legacy function calls do not carry a separate call_id. +// (role=function) into a function_call_output item. ToolCallID holds the ID +// assigned to the matching legacy call (see legacyFunctionCallIDs); without a +// matching call the Name field is used as call_id. func chatFunctionToResponses(m ChatMessage) ([]ResponsesInputItem, error) { output, err := parseChatContent(m.Content) if err != nil { @@ -313,9 +377,13 @@ func chatFunctionToResponses(m ChatMessage) ([]ResponsesInputItem, error) { if output == "" { output = "(empty)" } + callID := m.ToolCallID + if callID == "" { + callID = m.Name + } return []ResponsesInputItem{{ Type: "function_call_output", - CallID: m.Name, + CallID: callID, Output: output, }}, nil } diff --git a/backend/internal/pkg/apicompat/chatcompletions_to_responses_legacy_function_call_test.go b/backend/internal/pkg/apicompat/chatcompletions_to_responses_legacy_function_call_test.go new file mode 100644 index 000000000000..1c971fd087f5 --- /dev/null +++ b/backend/internal/pkg/apicompat/chatcompletions_to_responses_legacy_function_call_test.go @@ -0,0 +1,87 @@ +package apicompat + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +func convertLegacyFunctionHistory(t *testing.T, msgs []ChatMessage) []ResponsesInputItem { + t.Helper() + resp, err := ChatCompletionsToResponses(&ChatCompletionsRequest{Model: "gpt-4o", Messages: msgs}) + require.NoError(t, err) + var items []ResponsesInputItem + require.NoError(t, json.Unmarshal(resp.Input, &items)) + return items +} + +// Legacy function calling has no call IDs: the assistant turn carries +// function_call and the result is a role=function message keyed by name. The +// Responses API needs a function_call item and a function_call_output with the +// same call_id, otherwise the output is rejected as having no matching call. +func TestChatCompletionsToResponses_LegacyFunctionCallHistoryPaired(t *testing.T) { + msgs := []ChatMessage{ + {Role: "user", Content: json.RawMessage(`"Weather in Paris and Rome?"`)}, + {Role: "assistant", Content: json.RawMessage(`"Checking Paris."`), FunctionCall: &ChatFunctionCall{Name: "get_weather", Arguments: `{"city":"Paris"}`}}, + {Role: "function", Name: "get_weather", Content: json.RawMessage(`"sunny"`)}, + {Role: "assistant", FunctionCall: &ChatFunctionCall{Name: "get_weather", Arguments: `{"city":"Rome"}`}}, + {Role: "function", Name: "get_weather", Content: json.RawMessage(`"rainy"`)}, + } + + items := convertLegacyFunctionHistory(t, msgs) + require.Len(t, items, 6) + + require.Equal(t, "user", items[0].Role) + require.Equal(t, "assistant", items[1].Role) + + firstCall, firstOutput := items[2], items[3] + require.Equal(t, "function_call", firstCall.Type) + require.Equal(t, "get_weather", firstCall.Name) + require.Equal(t, `{"city":"Paris"}`, firstCall.Arguments) + require.Equal(t, "function_call_output", firstOutput.Type) + require.Equal(t, "sunny", firstOutput.Output) + require.NotEmpty(t, firstCall.CallID) + require.Equal(t, firstCall.CallID, firstOutput.CallID) + + secondCall, secondOutput := items[4], items[5] + require.Equal(t, "function_call", secondCall.Type) + require.Equal(t, `{"city":"Rome"}`, secondCall.Arguments) + require.Equal(t, "function_call_output", secondOutput.Type) + require.Equal(t, "rainy", secondOutput.Output) + require.Equal(t, secondCall.CallID, secondOutput.CallID) + require.NotEqual(t, firstCall.CallID, secondCall.CallID) + + // Synthesized IDs must be stable across turns so the replayed history keeps + // the same prompt prefix. + require.Equal(t, items, convertLegacyFunctionHistory(t, msgs)) +} + +func TestChatCompletionsToResponses_LegacyFunctionCallIDAvoidsToolCallIDs(t *testing.T) { + items := convertLegacyFunctionHistory(t, []ChatMessage{ + {Role: "user", Content: json.RawMessage(`"hi"`)}, + {Role: "assistant", FunctionCall: &ChatFunctionCall{Name: "lookup", Arguments: `{}`}}, + {Role: "function", Name: "lookup", Content: json.RawMessage(`"legacy result"`)}, + {Role: "assistant", ToolCalls: []ChatToolCall{{ID: "call_legacy_1", Type: "function", Function: ChatFunctionCall{Name: "lookup", Arguments: `{}`}}}}, + {Role: "tool", ToolCallID: "call_legacy_1", Content: json.RawMessage(`"tool result"`)}, + }) + require.Len(t, items, 5) + + legacyCall, legacyOutput := items[1], items[2] + require.Equal(t, "function_call", legacyCall.Type) + require.Equal(t, legacyCall.CallID, legacyOutput.CallID) + require.NotEqual(t, "call_legacy_1", legacyCall.CallID) + + require.Equal(t, "call_legacy_1", items[3].CallID) + require.Equal(t, "call_legacy_1", items[4].CallID) +} + +func TestChatCompletionsToResponses_LegacyFunctionResultWithoutCallKeepsName(t *testing.T) { + items := convertLegacyFunctionHistory(t, []ChatMessage{ + {Role: "user", Content: json.RawMessage(`"hi"`)}, + {Role: "function", Name: "lookup", Content: json.RawMessage(`"orphan"`)}, + }) + require.Len(t, items, 2) + require.Equal(t, "function_call_output", items[1].Type) + require.Equal(t, "lookup", items[1].CallID) +} From 15f469c86b77378a493aed688aa755ab2daa5a3f Mon Sep 17 00:00:00 2001 From: SPHINX998 <51872346+SPHINX998@users.noreply.github.com> Date: Thu, 1 Oct 2026 22:00:22 +0800 Subject: [PATCH 24/88] fix(gateway): ignore negative content block index in buffered Anthropic paths The buffered Anthropic aggregators index finalResp.Content with the upstream content_block_delta index after checking only idx < len. A malformed or third-party Anthropic-compatible upstream that sends a negative index makes the request goroutine panic, so the client gets no response and the forward never returns a result to record usage. Add the lower bound in all four buffered aggregators (Chat Completions and Responses, on both the Anthropic gateway and the OpenAI native Anthropic paths); the out-of-range delta is skipped like an index past the end. Co-Authored-By: Claude Opus 5.5 --- .../anthropic_buffered_negative_index_test.go | 97 +++++++++++++++++++ .../gateway_forward_as_chat_completions.go | 2 +- .../service/gateway_forward_as_responses.go | 2 +- ...teway_chat_completions_anthropic_native.go | 2 +- ...enai_gateway_responses_anthropic_native.go | 2 +- 5 files changed, 101 insertions(+), 4 deletions(-) create mode 100644 backend/internal/service/anthropic_buffered_negative_index_test.go diff --git a/backend/internal/service/anthropic_buffered_negative_index_test.go b/backend/internal/service/anthropic_buffered_negative_index_test.go new file mode 100644 index 000000000000..c5f4a8511e41 --- /dev/null +++ b/backend/internal/service/anthropic_buffered_negative_index_test.go @@ -0,0 +1,97 @@ +package service + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +// anthropicSSEWithNegativeBlockIndex is a buffered Anthropic stream in which a +// malformed upstream sends content_block_delta with a negative index. The +// buffered aggregators only bounded the index from above, so indexing +// finalResp.Content with it panicked. +func anthropicSSEWithNegativeBlockIndex() string { + return strings.Join([]string{ + `event: message_start`, + `data: {"type":"message_start","message":{"id":"msg_neg","type":"message","role":"assistant","content":[],"model":"claude-sonnet-4.5","usage":{"input_tokens":10}}}`, + ``, + `event: content_block_start`, + `data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`, + ``, + `event: content_block_delta`, + `data: {"type":"content_block_delta","index":-1,"delta":{"type":"text_delta","text":"IGNORED"}}`, + ``, + `event: content_block_delta`, + `data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}`, + ``, + `event: message_delta`, + `data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":5}}`, + ``, + `event: message_stop`, + `data: {"type":"message_stop"}`, + ``, + }, "\n") +} + +func TestAnthropicBufferedAggregators_IgnoreNegativeBlockIndex(t *testing.T) { + gin.SetMode(gin.TestMode) + + tests := []struct { + name string + run func(resp *http.Response, c *gin.Context) error + }{ + { + name: "gateway chat completions", + run: func(resp *http.Response, c *gin.Context) error { + _, err := (&GatewayService{}).handleCCBufferedFromAnthropic(resp, c, "gpt-5", "claude-sonnet-4.5", nil, time.Now()) + return err + }, + }, + { + name: "gateway responses", + run: func(resp *http.Response, c *gin.Context) error { + _, err := (&GatewayService{}).handleResponsesBufferedStreamingResponse(resp, c, "claude-sonnet-4.5", "claude-sonnet-4.5", nil, time.Now(), apicompat.ResponsesClientToolMapping{}) + return err + }, + }, + { + name: "openai native anthropic chat completions", + run: func(resp *http.Response, c *gin.Context) error { + _, err := newNativeAnthropicHangTestService(5).handleCCBufferedFromNativeAnthropic(resp, c, "glm-4.7", "glm-4.7", "glm-4.7", nil, time.Now()) + return err + }, + }, + { + name: "openai native anthropic responses", + run: func(resp *http.Response, c *gin.Context) error { + _, err := newNativeAnthropicHangTestService(5).handleResponsesBufferedFromNativeAnthropic(resp, c, "glm-4.7", "glm-4.7", "glm-4.7", nil, time.Now(), apicompat.ResponsesClientToolMapping{}) + return err + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/", nil) + resp := &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{}, + Body: io.NopCloser(strings.NewReader(anthropicSSEWithNegativeBlockIndex())), + } + + var err error + require.NotPanics(t, func() { err = tt.run(resp, c) }) + require.NoError(t, err) + require.Contains(t, rec.Body.String(), "Hello") + require.NotContains(t, rec.Body.String(), "IGNORED") + }) + } +} diff --git a/backend/internal/service/gateway_forward_as_chat_completions.go b/backend/internal/service/gateway_forward_as_chat_completions.go index 800bb8125972..5113fc6d091e 100644 --- a/backend/internal/service/gateway_forward_as_chat_completions.go +++ b/backend/internal/service/gateway_forward_as_chat_completions.go @@ -280,7 +280,7 @@ func (s *GatewayService) handleCCBufferedFromAnthropic( } if event.Type == "content_block_delta" && event.Delta != nil && finalResp != nil && event.Index != nil { idx := *event.Index - if idx < len(finalResp.Content) { + if idx >= 0 && idx < len(finalResp.Content) { switch event.Delta.Type { case "text_delta": finalResp.Content[idx].Text += event.Delta.Text diff --git a/backend/internal/service/gateway_forward_as_responses.go b/backend/internal/service/gateway_forward_as_responses.go index c360b95d2395..db8033ebe436 100644 --- a/backend/internal/service/gateway_forward_as_responses.go +++ b/backend/internal/service/gateway_forward_as_responses.go @@ -438,7 +438,7 @@ func (s *GatewayService) handleResponsesBufferedStreamingResponse( } if event.Type == "content_block_delta" && event.Delta != nil && finalResp != nil && event.Index != nil { idx := *event.Index - if idx < len(finalResp.Content) { + if idx >= 0 && idx < len(finalResp.Content) { switch event.Delta.Type { case "text_delta": finalResp.Content[idx].Text += event.Delta.Text diff --git a/backend/internal/service/openai_gateway_chat_completions_anthropic_native.go b/backend/internal/service/openai_gateway_chat_completions_anthropic_native.go index 5a937eff40f9..6c0affe2bf04 100644 --- a/backend/internal/service/openai_gateway_chat_completions_anthropic_native.go +++ b/backend/internal/service/openai_gateway_chat_completions_anthropic_native.go @@ -241,7 +241,7 @@ func (s *OpenAIGatewayService) handleCCBufferedFromNativeAnthropic( } if event.Type == "content_block_delta" && event.Delta != nil && finalResp != nil && event.Index != nil { idx := *event.Index - if idx < len(finalResp.Content) { + if idx >= 0 && idx < len(finalResp.Content) { switch event.Delta.Type { case "text_delta": finalResp.Content[idx].Text += event.Delta.Text diff --git a/backend/internal/service/openai_gateway_responses_anthropic_native.go b/backend/internal/service/openai_gateway_responses_anthropic_native.go index 69bbd7303950..09278d29149f 100644 --- a/backend/internal/service/openai_gateway_responses_anthropic_native.go +++ b/backend/internal/service/openai_gateway_responses_anthropic_native.go @@ -242,7 +242,7 @@ func (s *OpenAIGatewayService) handleResponsesBufferedFromNativeAnthropic( } if event.Type == "content_block_delta" && event.Delta != nil && finalResp != nil && event.Index != nil { idx := *event.Index - if idx < len(finalResp.Content) { + if idx >= 0 && idx < len(finalResp.Content) { switch event.Delta.Type { case "text_delta": finalResp.Content[idx].Text += event.Delta.Text From 775c1a3c1dcd23744b234623162149aa565b8eb0 Mon Sep 17 00:00:00 2001 From: SPHINX998 <51872346+SPHINX998@users.noreply.github.com> Date: Thu, 1 Oct 2026 21:58:20 +0800 Subject: [PATCH 25/88] fix(apicompat): preserve refusals when converting Responses to Chat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Responses refusal arrives as a {"type":"refusal","refusal":...} content part (streamed as response.refusal.delta), not as output_text. The Responses → Chat Completions converters only read output_text, and ResponsesContentPart had no refusal field, so Chat clients received an empty assistant message when the model refused. Map refusal parts to choices[0].message.refusal, response.refusal.delta to delta.refusal, and let BufferedResponseAccumulator keep refusal deltas so non-streaming replies rebuilt from deltas keep them too. Co-Authored-By: Claude Opus 5.5 --- .../apicompat/responses_to_chatcompletions.go | 46 +++++-- ...sponses_to_chatcompletions_refusal_test.go | 114 ++++++++++++++++++ backend/internal/pkg/apicompat/types.go | 5 +- 3 files changed, 153 insertions(+), 12 deletions(-) create mode 100644 backend/internal/pkg/apicompat/responses_to_chatcompletions_refusal_test.go diff --git a/backend/internal/pkg/apicompat/responses_to_chatcompletions.go b/backend/internal/pkg/apicompat/responses_to_chatcompletions.go index f402184f27e9..2b8f2b8117ca 100644 --- a/backend/internal/pkg/apicompat/responses_to_chatcompletions.go +++ b/backend/internal/pkg/apicompat/responses_to_chatcompletions.go @@ -15,7 +15,8 @@ import ( // ResponsesToChatCompletions converts a Responses API response into a Chat // Completions response. Text output items are concatenated into -// choices[0].message.content; function_call items become tool_calls. +// choices[0].message.content, refusal parts into choices[0].message.refusal; +// function_call items become tool_calls. func ResponsesToChatCompletions(resp *ResponsesResponse, model string) *ChatCompletionsResponse { id := resp.ID if id == "" { @@ -31,6 +32,7 @@ func ResponsesToChatCompletions(resp *ResponsesResponse, model string) *ChatComp } var contentText string + var refusalText string var reasoningText string var toolCalls []ChatToolCall @@ -38,8 +40,11 @@ func ResponsesToChatCompletions(resp *ResponsesResponse, model string) *ChatComp switch item.Type { case "message": for _, part := range item.Content { - if part.Type == "output_text" && part.Text != "" { + switch { + case part.Type == "output_text" && part.Text != "": contentText += part.Text + case part.Type == "refusal" && part.Refusal != "": + refusalText += part.Refusal } } case "function_call": @@ -62,7 +67,7 @@ func ResponsesToChatCompletions(resp *ResponsesResponse, model string) *ChatComp } } - msg := ChatMessage{Role: "assistant"} + msg := ChatMessage{Role: "assistant", Refusal: refusalText} if len(toolCalls) > 0 { msg.ToolCalls = toolCalls } @@ -149,6 +154,8 @@ func ResponsesEventToChatChunks(evt *ResponsesStreamEvent, state *ResponsesEvent return resToChatHandleCreated(evt, state) case "response.output_text.delta": return resToChatHandleTextDelta(evt, state) + case "response.refusal.delta": + return resToChatHandleRefusalDelta(evt, state) case "response.output_item.added": return resToChatHandleOutputItemAdded(evt, state) case "response.function_call_arguments.delta", @@ -248,6 +255,14 @@ func resToChatHandleTextDelta(evt *ResponsesStreamEvent, state *ResponsesEventTo return []ChatCompletionsChunk{makeChatDeltaChunk(state, ChatDelta{Content: &content})} } +func resToChatHandleRefusalDelta(evt *ResponsesStreamEvent, state *ResponsesEventToChatState) []ChatCompletionsChunk { + if evt.Delta == "" { + return nil + } + refusal := evt.Delta + return []ChatCompletionsChunk{makeChatDeltaChunk(state, ChatDelta{Refusal: &refusal})} +} + func resToChatHandleOutputItemAdded(evt *ResponsesStreamEvent, state *ResponsesEventToChatState) []ChatCompletionsChunk { // function_call 与 custom_tool_call(custom/freeform 工具)均按工具调用注册, // 以便后续 *_input.delta / *_arguments.delta 能映射到正确的工具索引。 @@ -496,6 +511,7 @@ type bufferedFuncCall struct { // (response.completed / response.done) carries an empty output array. type BufferedResponseAccumulator struct { text strings.Builder + refusal strings.Builder reasoning strings.Builder funcCalls []bufferedFuncCall outputIndexToFuncIdx map[int]int @@ -517,6 +533,10 @@ func (a *BufferedResponseAccumulator) ProcessEvent(event *ResponsesStreamEvent) if event.Delta != "" { _, _ = a.text.WriteString(event.Delta) } + case "response.refusal.delta": + if event.Delta != "" { + _, _ = a.refusal.WriteString(event.Delta) + } case "response.output_item.added": if event.Item != nil && (event.Item.Type == "function_call" || event.Item.Type == "custom_tool_call") { idx := len(a.funcCalls) @@ -553,7 +573,7 @@ func (a *BufferedResponseAccumulator) ProcessEvent(event *ResponsesStreamEvent) // HasContent reports whether any content has been accumulated. func (a *BufferedResponseAccumulator) HasContent() bool { - return a.text.Len() > 0 || len(a.funcCalls) > 0 || a.reasoning.Len() > 0 + return a.text.Len() > 0 || a.refusal.Len() > 0 || len(a.funcCalls) > 0 || a.reasoning.Len() > 0 } // BuildOutput constructs a []ResponsesOutput from the accumulated delta @@ -572,14 +592,18 @@ func (a *BufferedResponseAccumulator) BuildOutput() []ResponsesOutput { }) } - if a.text.Len() > 0 { + if a.text.Len() > 0 || a.refusal.Len() > 0 { + var content []ResponsesContentPart + if a.text.Len() > 0 { + content = append(content, ResponsesContentPart{Type: "output_text", Text: a.text.String()}) + } + if a.refusal.Len() > 0 { + content = append(content, ResponsesContentPart{Type: "refusal", Refusal: a.refusal.String()}) + } out = append(out, ResponsesOutput{ - Type: "message", - Role: "assistant", - Content: []ResponsesContentPart{{ - Type: "output_text", - Text: a.text.String(), - }}, + Type: "message", + Role: "assistant", + Content: content, }) } diff --git a/backend/internal/pkg/apicompat/responses_to_chatcompletions_refusal_test.go b/backend/internal/pkg/apicompat/responses_to_chatcompletions_refusal_test.go new file mode 100644 index 000000000000..cf8f0bfa4aa4 --- /dev/null +++ b/backend/internal/pkg/apicompat/responses_to_chatcompletions_refusal_test.go @@ -0,0 +1,114 @@ +package apicompat + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +const refusalText = "I can't help with that." + +func decodeResponsesEvent(t *testing.T, raw string) ResponsesStreamEvent { + t.Helper() + var evt ResponsesStreamEvent + require.NoError(t, json.Unmarshal([]byte(raw), &evt)) + return evt +} + +func chatMessageJSON(t *testing.T, resp *ChatCompletionsResponse) map[string]any { + t.Helper() + raw, err := json.Marshal(resp) + require.NoError(t, err) + var decoded struct { + Choices []struct { + Message map[string]any `json:"message"` + } `json:"choices"` + } + require.NoError(t, json.Unmarshal(raw, &decoded)) + require.Len(t, decoded.Choices, 1) + return decoded.Choices[0].Message +} + +// A refusal arrives as a "refusal" content part, not output_text. Dropping it +// leaves the Chat client with an empty assistant message. +func TestResponsesToChatCompletions_RefusalPreserved(t *testing.T) { + var resp ResponsesResponse + require.NoError(t, json.Unmarshal([]byte(`{ + "id":"resp_refusal", + "status":"completed", + "output":[{"type":"message","role":"assistant","content":[{"type":"refusal","refusal":"`+refusalText+`"}]}] + }`), &resp)) + + chat := ResponsesToChatCompletions(&resp, "gpt-4o") + + msg := chatMessageJSON(t, chat) + require.Equal(t, refusalText, msg["refusal"]) + require.NotContains(t, msg, "content") + require.Equal(t, "stop", chat.Choices[0].FinishReason) +} + +func TestResponsesEventToChatChunks_RefusalDelta(t *testing.T) { + state := NewResponsesEventToChatState() + events := []string{ + `{"type":"response.created","response":{"id":"resp_refusal","model":"gpt-4o"}}`, + `{"type":"response.output_item.added","output_index":0,"item":{"type":"message","role":"assistant"}}`, + `{"type":"response.refusal.delta","output_index":0,"content_index":0,"delta":"I can't "}`, + `{"type":"response.refusal.delta","output_index":0,"content_index":0,"delta":"help with that."}`, + `{"type":"response.refusal.done","output_index":0,"content_index":0,"refusal":"` + refusalText + `"}`, + `{"type":"response.completed","response":{"id":"resp_refusal","status":"completed"}}`, + } + + var refusal strings.Builder + var finishReason string + for _, raw := range events { + evt := decodeResponsesEvent(t, raw) + for _, chunk := range ResponsesEventToChatChunks(&evt, state) { + encoded, err := json.Marshal(chunk) + require.NoError(t, err) + var decoded struct { + Choices []struct { + Delta struct { + Refusal *string `json:"refusal"` + } `json:"delta"` + FinishReason *string `json:"finish_reason"` + } `json:"choices"` + } + require.NoError(t, json.Unmarshal(encoded, &decoded)) + for _, choice := range decoded.Choices { + if choice.Delta.Refusal != nil { + _, _ = refusal.WriteString(*choice.Delta.Refusal) + } + if choice.FinishReason != nil { + finishReason = *choice.FinishReason + } + } + } + } + + require.Equal(t, refusalText, refusal.String()) + require.Equal(t, "stop", finishReason) +} + +// Non-streaming Chat requests are served from a forced upstream stream; when +// the terminal event carries an empty output array the message is rebuilt from +// the accumulated deltas, which must include refusal deltas. +func TestBufferedResponseAccumulator_RefusalRebuiltFromDeltas(t *testing.T) { + acc := NewBufferedResponseAccumulator() + for _, raw := range []string{ + `{"type":"response.output_item.added","output_index":0,"item":{"type":"message","role":"assistant"}}`, + `{"type":"response.refusal.delta","output_index":0,"content_index":0,"delta":"I can't "}`, + `{"type":"response.refusal.delta","output_index":0,"content_index":0,"delta":"help with that."}`, + } { + evt := decodeResponsesEvent(t, raw) + acc.ProcessEvent(&evt) + } + require.True(t, acc.HasContent()) + + final := &ResponsesResponse{ID: "resp_refusal", Status: "completed"} + acc.SupplementResponseOutput(final) + chat := ResponsesToChatCompletions(final, "gpt-4o") + + require.Equal(t, refusalText, chatMessageJSON(t, chat)["refusal"]) +} diff --git a/backend/internal/pkg/apicompat/types.go b/backend/internal/pkg/apicompat/types.go index 859cb1dcb4d4..c0a833f107f8 100644 --- a/backend/internal/pkg/apicompat/types.go +++ b/backend/internal/pkg/apicompat/types.go @@ -312,8 +312,9 @@ func (i *ResponsesInputItem) UnmarshalJSON(data []byte) error { // ResponsesContentPart is a typed content part in a Responses message. type ResponsesContentPart struct { PromptCacheBreakpoint json.RawMessage `json:"prompt_cache_breakpoint,omitempty"` - Type string `json:"type"` // "input_text" | "output_text" | "input_image" | "input_file" + Type string `json:"type"` // "input_text" | "output_text" | "refusal" | "input_image" | "input_file" Text string `json:"text,omitempty"` + Refusal string `json:"refusal,omitempty"` // type=refusal ImageURL string `json:"image_url,omitempty"` // data URI for input_image // input_file fields. @@ -690,6 +691,7 @@ type ChatStreamOptions struct { type ChatMessage struct { Role string `json:"role"` // "system" | "user" | "assistant" | "tool" | "function" Content json.RawMessage `json:"content,omitempty"` + Refusal string `json:"refusal,omitempty"` ReasoningContent string `json:"reasoning_content,omitempty"` Reasoning string `json:"reasoning,omitempty"` Name string `json:"name,omitempty"` @@ -830,6 +832,7 @@ type ChatChunkChoice struct { type ChatDelta struct { Role string `json:"role,omitempty"` Content *string `json:"content,omitempty"` // pointer: omit when not present, null vs "" matters + Refusal *string `json:"refusal,omitempty"` ReasoningContent *string `json:"reasoning_content,omitempty"` Reasoning *string `json:"reasoning,omitempty"` ToolCalls []ChatToolCall `json:"tool_calls,omitempty"` From b6a157c19c5741aa8b7b06f14e1db2b8270a7311 Mon Sep 17 00:00:00 2001 From: SPHINX998 <51872346+SPHINX998@users.noreply.github.com> Date: Thu, 1 Oct 2026 22:30:02 +0800 Subject: [PATCH 26/88] fix(openai): account image input usage on WS passthrough The WS v2 passthrough relay only read output_tokens_details.image_tokens, so image generation over WebSocket billed image input tokens at the text input rate and ignored the hosted response.tool_usage.image_gen breakdown that the HTTP Responses path already merges (extractOpenAIUsageFromJSONBytes + mergeHostedImageGenToolUsage). The relay now reads image input tokens from input_tokens_details or prompt_tokens_details, fills missing image input/output counters from response.tool_usage.image_gen (usage values keep precedence), and carries ImageInputTokens per turn and in the session aggregate into the OpenAIForwardResult used for billing. Co-Authored-By: Claude Opus 5.5 --- .../service/openai_ws_v2/passthrough_relay.go | 23 +++- .../passthrough_relay_image_usage_test.go | 47 ++++++++ .../openai_ws_v2_passthrough_adapter.go | 2 + ...enai_ws_v2_passthrough_image_usage_test.go | 103 ++++++++++++++++++ 4 files changed, 174 insertions(+), 1 deletion(-) create mode 100644 backend/internal/service/openai_ws_v2/passthrough_relay_image_usage_test.go create mode 100644 backend/internal/service/openai_ws_v2_passthrough_image_usage_test.go diff --git a/backend/internal/service/openai_ws_v2/passthrough_relay.go b/backend/internal/service/openai_ws_v2/passthrough_relay.go index 3c6bd076dcef..0fdbb9085232 100644 --- a/backend/internal/service/openai_ws_v2/passthrough_relay.go +++ b/backend/internal/service/openai_ws_v2/passthrough_relay.go @@ -26,6 +26,7 @@ type FrameConn interface { type Usage struct { InputTokens int + ImageInputTokens int OutputTokens int CacheCreationInputTokens int CacheReadInputTokens int @@ -1117,6 +1118,21 @@ func parseUsageAndAccumulate( if imageTokens == 0 { imageTokens = usageResult.Get("completion_tokens_details.image_tokens").Int() } + imageInputTokens := usageResult.Get("input_tokens_details.image_tokens").Int() + if imageInputTokens <= 0 { + imageInputTokens = usageResult.Get("prompt_tokens_details.image_tokens").Int() + } + // 与 HTTP Responses 同口径:usage 缺图片计数时由托管工具 tool_usage.image_gen 回填。 + imageGen := gjson.GetBytes(message, "response.tool_usage.image_gen") + if !imageGen.Exists() { + imageGen = gjson.GetBytes(message, "tool_usage.image_gen") + } + if imageTokens <= 0 { + imageTokens = max(imageGen.Get("output_tokens_details.image_tokens").Int(), 0) + } + if imageInputTokens <= 0 { + imageInputTokens = max(imageGen.Get("input_tokens_details.image_tokens").Int(), 0) + } requireTotals := isTerminalEvent(strings.TrimSpace(eventType)) inputTokens, inputOK := parseUsageIntField(inputResult, requireTotals) @@ -1141,6 +1157,7 @@ func parseUsageAndAccumulate( } parsedUsage := Usage{ InputTokens: inputTokens, + ImageInputTokens: int(imageInputTokens), OutputTokens: outputTokens, CacheCreationInputTokens: openAICacheCreationTokensFromUsage(usageResult), CacheReadInputTokens: cachedTokens, @@ -1161,7 +1178,7 @@ func parseUsageAndAccumulate( func relayUsageHasTokens(usage Usage) bool { return usage.InputTokens > 0 || usage.OutputTokens > 0 || usage.CacheCreationInputTokens > 0 || usage.CacheReadInputTokens > 0 || - usage.ImageOutputTokens > 0 + usage.ImageInputTokens > 0 || usage.ImageOutputTokens > 0 } func mergeRelayUsageNonZero(dst *Usage, src Usage) { @@ -1180,6 +1197,9 @@ func mergeRelayUsageNonZero(dst *Usage, src Usage) { if src.CacheReadInputTokens > 0 { dst.CacheReadInputTokens = src.CacheReadInputTokens } + if src.ImageInputTokens > 0 { + dst.ImageInputTokens = src.ImageInputTokens + } if src.ImageOutputTokens > 0 { dst.ImageOutputTokens = src.ImageOutputTokens } @@ -1191,6 +1211,7 @@ func finalizeRelayTurnUsage(state *relayState) Usage { } turnUsage := state.turnUsage state.usage.InputTokens += turnUsage.InputTokens + state.usage.ImageInputTokens += turnUsage.ImageInputTokens state.usage.OutputTokens += turnUsage.OutputTokens state.usage.CacheCreationInputTokens += turnUsage.CacheCreationInputTokens state.usage.CacheReadInputTokens += turnUsage.CacheReadInputTokens diff --git a/backend/internal/service/openai_ws_v2/passthrough_relay_image_usage_test.go b/backend/internal/service/openai_ws_v2/passthrough_relay_image_usage_test.go new file mode 100644 index 000000000000..74beacd6c96c --- /dev/null +++ b/backend/internal/service/openai_ws_v2/passthrough_relay_image_usage_test.go @@ -0,0 +1,47 @@ +package openai_ws_v2 + +import ( + "context" + "testing" + "time" + + coderws "github.com/coder/websocket" + "github.com/stretchr/testify/require" +) + +// WS passthrough must account image tokens the way the HTTP Responses path +// does: image input from input/prompt_tokens_details, and the hosted +// tool_usage.image_gen breakdown filling counters the usage object omits. +func TestRelay_ImageUsageMatchesHTTPResponsesAccounting(t *testing.T) { + t.Parallel() + + clientConn := newPassthroughTestFrameConn(nil, false) + upstreamConn := newPassthroughTestFrameConn([]passthroughTestFrame{ + {msgType: coderws.MessageText, payload: []byte(`{"type":"response.completed","response":{"id":"resp_tool_image","usage":{"input_tokens":100,"output_tokens":60},"tool_usage":{"image_gen":{"input_tokens_details":{"image_tokens":80},"output_tokens_details":{"image_tokens":50}}}}}`)}, + {msgType: coderws.MessageText, payload: []byte(`{"type":"response.completed","response":{"id":"resp_explicit_image","usage":{"input_tokens":30,"output_tokens":15,"input_tokens_details":{"image_tokens":20},"output_tokens_details":{"image_tokens":10}},"tool_usage":{"image_gen":{"input_tokens_details":{"image_tokens":800},"output_tokens_details":{"image_tokens":500}}}}}`)}, + {msgType: coderws.MessageText, payload: []byte(`{"type":"response.completed","response":{"id":"resp_prompt_details","usage":{"prompt_tokens":12,"completion_tokens":2,"prompt_tokens_details":{"image_tokens":7}}}}`)}, + {msgType: coderws.MessageText, payload: []byte(`{"type":"response.completed","response":{"id":"resp_text","usage":{"input_tokens":3,"output_tokens":4}}}`)}, + }, true) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + var turns []RelayTurnResult + result, relayExit := Relay(ctx, clientConn, upstreamConn, + []byte(`{"type":"response.create","model":"gpt-5.4","tools":[{"type":"image_generation"}],"input":[]}`), + RelayOptions{OnTurnComplete: func(turn RelayTurnResult) { turns = append(turns, turn) }}) + require.Nil(t, relayExit) + require.Len(t, turns, 4) + + require.Equal(t, 80, turns[0].Usage.ImageInputTokens, "tool_usage.image_gen fills a missing image input counter") + require.Equal(t, 50, turns[0].Usage.ImageOutputTokens, "tool_usage.image_gen fills a missing image output counter") + require.Equal(t, 20, turns[1].Usage.ImageInputTokens, "response usage takes precedence over the hosted tool breakdown") + require.Equal(t, 10, turns[1].Usage.ImageOutputTokens, "response usage takes precedence over the hosted tool breakdown") + require.Equal(t, 7, turns[2].Usage.ImageInputTokens) + require.Zero(t, turns[3].Usage.ImageInputTokens, "a text turn must not reuse earlier image usage") + require.Zero(t, turns[3].Usage.ImageOutputTokens, "a text turn must not reuse earlier image usage") + + require.Equal(t, 107, result.Usage.ImageInputTokens) + require.Equal(t, 60, result.Usage.ImageOutputTokens) + require.Equal(t, 145, result.Usage.InputTokens) + require.Equal(t, 81, result.Usage.OutputTokens) +} diff --git a/backend/internal/service/openai_ws_v2_passthrough_adapter.go b/backend/internal/service/openai_ws_v2_passthrough_adapter.go index 556ad0627436..5b5f25e37f22 100644 --- a/backend/internal/service/openai_ws_v2_passthrough_adapter.go +++ b/backend/internal/service/openai_ws_v2_passthrough_adapter.go @@ -1207,6 +1207,7 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( RequestID: turn.RequestID, Usage: OpenAIUsage{ InputTokens: turn.Usage.InputTokens, + ImageInputTokens: turn.Usage.ImageInputTokens, OutputTokens: turn.Usage.OutputTokens, CacheCreationInputTokens: turn.Usage.CacheCreationInputTokens, CacheReadInputTokens: turn.Usage.CacheReadInputTokens, @@ -1348,6 +1349,7 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( RequestID: relayResult.RequestID, Usage: OpenAIUsage{ InputTokens: relayResult.Usage.InputTokens, + ImageInputTokens: relayResult.Usage.ImageInputTokens, OutputTokens: relayResult.Usage.OutputTokens, CacheCreationInputTokens: relayResult.Usage.CacheCreationInputTokens, CacheReadInputTokens: relayResult.Usage.CacheReadInputTokens, diff --git a/backend/internal/service/openai_ws_v2_passthrough_image_usage_test.go b/backend/internal/service/openai_ws_v2_passthrough_image_usage_test.go new file mode 100644 index 000000000000..06d66df62d7d --- /dev/null +++ b/backend/internal/service/openai_ws_v2_passthrough_image_usage_test.go @@ -0,0 +1,103 @@ +package service + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + coderws "github.com/coder/websocket" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +// The billed turn result of a WS passthrough image generation must carry the +// image input tokens and the tool_usage.image_gen backfill, as HTTP does. +func TestOpenAIWSPassthroughTurnResultCarriesImageUsage(t *testing.T) { + gin.SetMode(gin.TestMode) + + cfg := &config.Config{} + cfg.Security.URLAllowlist.Enabled = false + cfg.Security.URLAllowlist.AllowInsecureHTTP = true + cfg.Gateway.OpenAIWS.Enabled = true + cfg.Gateway.OpenAIWS.APIKeyEnabled = true + cfg.Gateway.OpenAIWS.ResponsesWebsocketsV2 = true + cfg.Gateway.OpenAIWS.ModeRouterV2Enabled = true + cfg.Gateway.OpenAIWS.IngressModeDefault = OpenAIWSIngressModeCtxPool + cfg.Gateway.OpenAIWS.DialTimeoutSeconds = 3 + cfg.Gateway.OpenAIWS.ReadTimeoutSeconds = 3 + cfg.Gateway.OpenAIWS.WriteTimeoutSeconds = 3 + + upstreamConn := &openAIWSCaptureConn{ + events: [][]byte{ + []byte(`{"type":"response.completed","response":{"id":"resp_ws_image","model":"gpt-5.4","output":[{"id":"ig_ws_1","type":"image_generation_call","status":"completed","result":"final-image"}],"usage":{"input_tokens":120,"output_tokens":70,"input_tokens_details":{"image_tokens":40}},"tool_usage":{"image_gen":{"input_tokens_details":{"image_tokens":900},"output_tokens_details":{"image_tokens":55}}}}}`), + }, + } + svc := &OpenAIGatewayService{ + cfg: cfg, + httpUpstream: &httpUpstreamRecorder{}, + cache: &stubGatewayCache{}, + openaiWSResolver: NewOpenAIWSProtocolResolver(cfg), + toolCorrector: NewCodexToolCorrector(), + openaiWSPassthroughDialer: &openAIWSCaptureDialer{conn: upstreamConn}, + } + account := &Account{ + ID: 453, Name: "openai-ws-image-usage", Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Status: StatusActive, Schedulable: true, Concurrency: 1, + Credentials: map[string]any{"api_key": "sk-test"}, + Extra: map[string]any{"openai_apikey_responses_websockets_v2_mode": OpenAIWSIngressModePassthrough}, + } + + resultCh := make(chan *OpenAIForwardResult, 1) + hooks := &OpenAIWSIngressHooks{ + AfterTurn: func(_ int, result *OpenAIForwardResult, turnErr error) { + if turnErr == nil && result != nil { + resultCh <- result + } + }, + } + wsServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := coderws.Accept(w, r, nil) + if err != nil { + return + } + defer func() { _ = conn.CloseNow() }() + ginCtx, _ := gin.CreateTestContext(httptest.NewRecorder()) + ginCtx.Request = r.Clone(r.Context()) + readCtx, cancel := context.WithTimeout(r.Context(), 3*time.Second) + _, firstMessage, readErr := conn.Read(readCtx) + cancel() + if readErr != nil { + return + } + _ = svc.ProxyResponsesWebSocketFromClient(r.Context(), ginCtx, conn, account, "sk-test", firstMessage, hooks) + })) + defer wsServer.Close() + + dialCtx, cancelDial := context.WithTimeout(context.Background(), 3*time.Second) + clientConn, _, err := coderws.Dial(dialCtx, "ws"+strings.TrimPrefix(wsServer.URL, "http"), nil) + cancelDial() + require.NoError(t, err) + defer func() { _ = clientConn.CloseNow() }() + + writeCtx, cancelWrite := context.WithTimeout(context.Background(), 3*time.Second) + require.NoError(t, clientConn.Write(writeCtx, coderws.MessageText, []byte(`{"type":"response.create","model":"gpt-5.4","tools":[{"type":"image_generation"}],"input":"draw"}`))) + cancelWrite() + readCtx, cancelRead := context.WithTimeout(context.Background(), 3*time.Second) + _, _, readErr := clientConn.Read(readCtx) + cancelRead() + require.NoError(t, readErr) + _ = clientConn.Close(coderws.StatusNormalClosure, "done") + + select { + case result := <-resultCh: + require.Equal(t, 120, result.Usage.InputTokens) + require.Equal(t, 40, result.Usage.ImageInputTokens, "usage image input wins over the hosted tool breakdown") + require.Equal(t, 55, result.Usage.ImageOutputTokens, "tool_usage.image_gen fills the missing image output counter") + case <-time.After(5 * time.Second): + t.Fatal("passthrough turn result was not reported") + } +} From 1888de135dd76378ee7a9c6f30ac42aeac88da5f Mon Sep 17 00:00:00 2001 From: wucm667 Date: Thu, 1 Oct 2026 22:54:05 +0800 Subject: [PATCH 27/88] fix(models): list composite exact route aliases --- backend/internal/handler/gateway_handler.go | 16 +++- .../internal/handler/gateway_models_test.go | 78 ++++++++++++++++++- .../service/composite_route_resolver.go | 21 +++++ backend/internal/service/gateway_service.go | 8 ++ 4 files changed, 118 insertions(+), 5 deletions(-) diff --git a/backend/internal/handler/gateway_handler.go b/backend/internal/handler/gateway_handler.go index f83494ef9a07..165442ddd04d 100644 --- a/backend/internal/handler/gateway_handler.go +++ b/backend/internal/handler/gateway_handler.go @@ -1162,7 +1162,7 @@ func (h *GatewayHandler) Models(c *gin.Context) { } if platform == service.PlatformComposite { - availableModels := h.compositeAvailableModels(c.Request.Context(), groupID) + availableModels := h.compositeAvailableModels(c.Request.Context(), groupID, "") if apiKey != nil && apiKey.Group != nil && apiKey.Group.ModelAllowlistEnabled() { source := availableModels if len(source) == 0 { @@ -1257,7 +1257,7 @@ func (h *GatewayHandler) codexModelIDsForGroup(ctx context.Context, group *servi platform = group.Platform } if platform == service.PlatformComposite { - availableModels := h.compositeAvailableModels(ctx, groupID) + availableModels := h.compositeAvailableModels(ctx, groupID, service.CompositeRouteEndpointResponses) fallbackModels := defaultCodexModelIDsForPlatform(service.PlatformComposite) if group.ModelAllowlistEnabled() { source := availableModels @@ -1283,7 +1283,7 @@ func (h *GatewayHandler) codexModelIDsForGroup(ctx context.Context, group *servi return fallbackModels } -func (h *GatewayHandler) compositeAvailableModels(ctx context.Context, groupID *int64) []string { +func (h *GatewayHandler) compositeAvailableModels(ctx context.Context, groupID *int64, endpoint string) []string { if h == nil || h.gatewayService == nil { return nil } @@ -1311,6 +1311,16 @@ func (h *GatewayHandler) compositeAvailableModels(ctx context.Context, groupID * models = append(models, model) } } + // A route can expose a public ID that no account model mapping contains. + // On lookup failure, retain the existing account-derived catalog only. + if routeModels, err := h.gatewayService.GetCompositeRouteModels(ctx, groupID, endpoint); err == nil { + for _, model := range routeModels { + if _, ok := seen[model]; !ok { + seen[model] = struct{}{} + models = append(models, model) + } + } + } return models } diff --git a/backend/internal/handler/gateway_models_test.go b/backend/internal/handler/gateway_models_test.go index 0b49ca5f3586..793f22742fb8 100644 --- a/backend/internal/handler/gateway_models_test.go +++ b/backend/internal/handler/gateway_models_test.go @@ -3,6 +3,7 @@ package handler import ( "context" "encoding/json" + "errors" "net/http" "net/http/httptest" "strings" @@ -22,6 +23,16 @@ type gatewayModelsAccountRepoStub struct { byGroup map[int64][]service.Account } +type gatewayModelsRouteRepoStub struct { + service.CompositeModelRouteRepository + routes []service.CompositeModelRoute + err error +} + +func (s *gatewayModelsRouteRepoStub) ListByGroup(_ context.Context, _ int64, _ bool) ([]service.CompositeModelRoute, error) { + return s.routes, s.err +} + type gatewayModelsResponseForTest struct { Object string `json:"object"` Data []gatewayModelItemForTest `json:"data"` @@ -81,16 +92,79 @@ func (s *gatewayModelsAccountRepoStub) ListModelAvailabilityCandidates(ctx conte return s.ListSchedulableByGroupID(ctx, *groupID) } -func newGatewayModelsHandlerForTest(repo service.AccountRepository) *GatewayHandler { +func newGatewayModelsHandlerForTest(repo service.AccountRepository, routeRepo ...service.CompositeModelRouteRepository) *GatewayHandler { + var resolver *service.CompositeRouteResolver + if len(routeRepo) > 0 { + resolver = service.NewCompositeRouteResolver(routeRepo[0]) + } return &GatewayHandler{ gatewayService: service.NewGatewayService( repo, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, - nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, + nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, resolver, nil, nil, ), } } +func TestGatewayCompositeRouteModelsAppearInBothCatalogs(t *testing.T) { + gin.SetMode(gin.TestMode) + const groupID int64 = 7784 + accounts := &gatewayModelsAccountRepoStub{byGroup: map[int64][]service.Account{ + groupID: {{ID: 1, Platform: service.PlatformOpenAI, Credentials: map[string]any{ + "model_mapping": map[string]any{"mapped-model": "upstream"}, + }}}, + }} + routes := &gatewayModelsRouteRepoStub{routes: []service.CompositeModelRoute{ + {PublicModel: "route-only", MatchType: service.CompositeRouteMatchExact, Enabled: true}, + {PublicModel: "route-only", MatchType: service.CompositeRouteMatchExact, Enabled: true}, + {PublicModel: "mapped-model", MatchType: service.CompositeRouteMatchExact, Enabled: true}, + {PublicModel: "image-only", MatchType: service.CompositeRouteMatchExact, Endpoint: service.CompositeRouteEndpointImages, Enabled: true}, + {PublicModel: "prefix-", MatchType: service.CompositeRouteMatchPrefix, Enabled: true}, + {PublicModel: "disabled", MatchType: service.CompositeRouteMatchExact, Enabled: false}, + }} + for _, tc := range []struct { + name string + repo service.CompositeModelRouteRepository + allowlist []string + want []string + }{ + {"routes", routes, nil, []string{"mapped-model", "route-only", "image-only"}}, + {"allowlist", routes, []string{"route-only", "disabled", "unknown"}, []string{"route-only"}}, + {"lookup failure", &gatewayModelsRouteRepoStub{err: errors.New("unavailable")}, nil, []string{"mapped-model"}}, + {"no route repo", nil, nil, []string{"mapped-model"}}, + } { + t.Run(tc.name, func(t *testing.T) { + h := newGatewayModelsHandlerForTest(accounts, tc.repo) + group := &service.Group{ID: groupID, Platform: service.PlatformComposite} + if tc.allowlist != nil { + group.ModelAllowlist = service.GroupModelAllowlist{Enabled: true, Models: tc.allowlist} + } + for _, endpoint := range []string{"models", "codex"} { + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil) + c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{Group: group}) + if endpoint == "models" { + h.Models(c) + var got gatewayModelsResponseForTest + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + require.Equal(t, tc.want, modelIDsForTest(got.Data)) + } else { + h.CodexModels(c) + var got codexModelsResponseForTest + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + want := tc.want + if tc.name == "routes" { + want = []string{"mapped-model", "route-only"} + } + require.ElementsMatch(t, want, codexModelSlugsForTest(got.Models)) + } + require.Equal(t, http.StatusOK, rec.Code) + } + }) + } +} + func TestDefaultModelIDsForCompositeIncludesAntigravityDefaults(t *testing.T) { antigravityIDs := defaultModelIDsForPlatform(service.PlatformAntigravity) require.NotEmpty(t, antigravityIDs) diff --git a/backend/internal/service/composite_route_resolver.go b/backend/internal/service/composite_route_resolver.go index f2e1928ace34..450175c5a051 100644 --- a/backend/internal/service/composite_route_resolver.go +++ b/backend/internal/service/composite_route_resolver.go @@ -22,6 +22,27 @@ func (r *CompositeRouteResolver) SetModelOwnershipResolver(resolver CompositeMod } } +// ListExactPublicModels returns enabled, concrete route IDs suitable for a model catalog. +func (r *CompositeRouteResolver) ListExactPublicModels(ctx context.Context, groupID int64, endpoint string) ([]string, error) { + if r == nil || r.repo == nil || groupID <= 0 { + return nil, nil + } + routes, err := r.repo.ListByGroup(ctx, groupID, false) + if err != nil { + return nil, err + } + models := make([]string, 0, len(routes)) + for _, route := range routes { + if route.Enabled && route.MatchType == CompositeRouteMatchExact && + (endpoint == "" || normalizeCompositeRouteEndpoint(route.Endpoint) == CompositeRouteEndpointAny || normalizeCompositeRouteEndpoint(route.Endpoint) == endpoint) { + if model := strings.TrimSpace(route.PublicModel); model != "" { + models = append(models, model) + } + } + } + return models, nil +} + func (r *CompositeRouteResolver) Resolve(ctx context.Context, groupID int64, model, endpoint string) (CompositeRouteDecision, error) { model = strings.TrimSpace(model) endpoint = normalizeCompositeRouteEndpoint(endpoint) diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index eb782d9a1e5f..db0e095a86de 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -1535,6 +1535,14 @@ func explicitModelMappingClaims(account Account, model string) bool { return ok && strings.TrimSpace(mapped) != "" } +// GetCompositeRouteModels returns public IDs from enabled exact composite routes. +func (s *GatewayService) GetCompositeRouteModels(ctx context.Context, groupID *int64, endpoint string) ([]string, error) { + if s == nil || s.compositeResolver == nil || groupID == nil { + return nil, nil + } + return s.compositeResolver.ListExactPublicModels(ctx, *groupID, endpoint) +} + // GetSchedulablePlatforms returns the concrete platforms that currently have // schedulable accounts in the target group. func (s *GatewayService) GetSchedulablePlatforms(ctx context.Context, groupID *int64) map[string]struct{} { From 440784914210ab4e628699752b6a3d3554bc6412 Mon Sep 17 00:00:00 2001 From: duckducktw Date: Thu, 1 Oct 2026 23:59:58 +0800 Subject: [PATCH 28/88] =?UTF-8?q?fix(channels):=20=E8=BF=BD=E5=8A=A0?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E6=97=B6=E4=B8=8D=E5=86=8D=E6=94=B9=E5=86=99?= =?UTF-8?q?=E5=B7=B2=E7=95=99=E7=A9=BA=E7=9A=84=E4=BB=B7=E6=A0=BC=E6=9D=A1?= =?UTF-8?q?=E7=9B=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 渠道定价条目价格留空时按各模型官方默认价回退计费,但向这类条目追加 模型时,前端会用「第一个新增模型」的默认价回填整条价格字段,保存后 条目内所有模型被静默统一成同一个价格,造成少计费或多计费。 自动填充现在只作用于「空条目首次加入单个模型」:条目已有模型时追加、 或一次加入多个模型时都不再改写价格,留空条目继续按各模型默认价回退。 - 新增 PricingEntryCard.modelDefaultPrice.spec.ts 覆盖 4 种场景 - 把该 spec 加入 Makefile 的 FRONTEND_CRITICAL_VITEST,使 CI 覆盖该回归 Closes #7796 --- Makefile | 3 +- .../admin/channel/PricingEntryCard.vue | 6 +- ...PricingEntryCard.modelDefaultPrice.spec.ts | 108 ++++++++++++++++++ 3 files changed, 114 insertions(+), 3 deletions(-) create mode 100644 frontend/src/components/admin/channel/__tests__/PricingEntryCard.modelDefaultPrice.spec.ts diff --git a/Makefile b/Makefile index e205902e2f8a..e83f00193733 100644 --- a/Makefile +++ b/Makefile @@ -21,7 +21,8 @@ FRONTEND_CRITICAL_VITEST := \ src/views/admin/__tests__/SettingsView.spec.ts \ src/features/channel-monitor-v2/__tests__/designSystem.structure.spec.ts \ src/features/channel-monitor-v2/__tests__/monitorFormat.spec.ts \ - src/features/channel-monitor-v2/__tests__/monitorZoom.spec.ts + src/features/channel-monitor-v2/__tests__/monitorZoom.spec.ts \ + src/components/admin/channel/__tests__/PricingEntryCard.modelDefaultPrice.spec.ts # 一键编译前后端 build: build-backend build-frontend diff --git a/frontend/src/components/admin/channel/PricingEntryCard.vue b/frontend/src/components/admin/channel/PricingEntryCard.vue index f2448d6d08a3..4c59e20a2913 100644 --- a/frontend/src/components/admin/channel/PricingEntryCard.vue +++ b/frontend/src/components/admin/channel/PricingEntryCard.vue @@ -411,9 +411,11 @@ async function onModelsUpdate(newModels: string[]) { const oldModels = props.entry.models emit('update', { ...props.entry, models: newModels }) - // 只在新增模型且当前无价格时自动填充 + // 仅在「空条目首次加入单个模型」时自动填充默认价: + // 条目已有模型时(即使价格留空、按各模型的官方默认价回退)追加模型不应改写整条价格, + // 一次加入多个模型也不应把第一个模型的价格套用到整条,否则条目内所有模型都会被静默改价。 const addedModels = newModels.filter(m => !oldModels.includes(m)) - if (addedModels.length === 0) return + if (oldModels.length !== 0 || addedModels.length !== 1) return // 检查是否所有价格字段都为空 const e = props.entry diff --git a/frontend/src/components/admin/channel/__tests__/PricingEntryCard.modelDefaultPrice.spec.ts b/frontend/src/components/admin/channel/__tests__/PricingEntryCard.modelDefaultPrice.spec.ts new file mode 100644 index 000000000000..3eaab643a70f --- /dev/null +++ b/frontend/src/components/admin/channel/__tests__/PricingEntryCard.modelDefaultPrice.spec.ts @@ -0,0 +1,108 @@ +import { flushPromises, shallowMount } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import PricingEntryCard from '../PricingEntryCard.vue' +import type { PricingFormEntry } from '../types' +import channelsAPI from '@/api/admin/channels' + +vi.mock('@/api/admin/channels', () => ({ + default: { getModelDefaultPricing: vi.fn() }, +})) + +vi.mock('vue-i18n', async importOriginal => ({ + ...await importOriginal(), + useI18n: () => ({ t: (key: string) => key }), +})) + +const getModelDefaultPricing = vi.mocked(channelsAPI.getModelDefaultPricing) + +function createEntry(models: string[] = []): PricingFormEntry { + return { + models, + billing_mode: 'token', + input_price: null, + output_price: null, + cache_write_price: null, + cache_read_price: null, + fast_multiplier: null, + flex_multiplier: null, + reasoning_effort_multipliers: null, + image_input_price: null, + image_output_price: null, + per_request_price: null, + intervals: [], + time_pricing: { + timezone: 'Asia/Shanghai', + weekdays_only: false, + periods: [], + }, + } +} + +function addModels(entry: PricingFormEntry, models: string[]) { + const wrapper = shallowMount(PricingEntryCard, { props: { entry } }) + wrapper.findComponent({ name: 'ModelTagInput' }).vm.$emit('update:models', models) + return wrapper +} + +const emittedEntries = (wrapper: ReturnType) => + (wrapper.emitted('update') ?? []).map(e => e[0] as PricingFormEntry) + +describe('PricingEntryCard default price auto-fill', () => { + beforeEach(() => { + vi.clearAllMocks() + getModelDefaultPricing.mockResolvedValue({ found: true, input_price: 3e-6, output_price: 15e-6 }) + }) + + it('auto-fills the default price when a model is added to an empty entry', async () => { + const wrapper = addModels(createEntry(), ['claude-opus-4-8']) + await flushPromises() + + expect(getModelDefaultPricing).toHaveBeenCalledTimes(1) + expect(getModelDefaultPricing).toHaveBeenCalledWith('claude-opus-4-8') + expect(emittedEntries(wrapper).at(-1)).toMatchObject({ + models: ['claude-opus-4-8'], + input_price: 3, + output_price: 15, + }) + }) + + it('does not overwrite prices when appending a model to an entry that already has one', async () => { + const entry = createEntry(['claude-opus-4-7']) + const wrapper = addModels(entry, ['claude-opus-4-7', 'claude-opus-4-8']) + await flushPromises() + + expect(getModelDefaultPricing).not.toHaveBeenCalled() + expect(emittedEntries(wrapper).at(-1)).toMatchObject({ + models: ['claude-opus-4-7', 'claude-opus-4-8'], + }) + for (const emitted of emittedEntries(wrapper)) { + expect(emitted.input_price).toBeNull() + expect(emitted.output_price).toBeNull() + expect(emitted.cache_write_price).toBeNull() + expect(emitted.cache_read_price).toBeNull() + } + }) + + it('does not auto-fill a shared price when several models are added to an empty entry at once', async () => { + const wrapper = addModels(createEntry(), ['claude-opus-4-7', 'claude-opus-4-8']) + await flushPromises() + + expect(getModelDefaultPricing).not.toHaveBeenCalled() + expect(emittedEntries(wrapper).at(-1)).toMatchObject({ + models: ['claude-opus-4-7', 'claude-opus-4-8'], + input_price: null, + output_price: null, + }) + }) + + it('keeps manual prices untouched when a model is appended', async () => { + const entry = { ...createEntry(['claude-opus-4-7']), input_price: '1.5' } + const wrapper = addModels(entry, ['claude-opus-4-7', 'claude-opus-4-8']) + await flushPromises() + + expect(getModelDefaultPricing).not.toHaveBeenCalled() + for (const emitted of emittedEntries(wrapper)) { + expect(emitted.input_price).toBe('1.5') + } + }) +}) From c0e5a4670e24a5a63f54272abafbf0acd64f1016 Mon Sep 17 00:00:00 2001 From: Kingsley <10992364+kingsleydon@users.noreply.github.com> Date: Fri, 2 Oct 2026 09:38:08 +0000 Subject: [PATCH 29/88] fix(codex): never advertise null service_tiers in models manifest intersectUpstreamModelMetadata falls back to null for every Codex tool capability field whose value differs across peer accounts. Since e6d191a83 added service_tiers to codexToolCapabilityFields, a model served by accounts with different (or partially declared) tier lists ends up with "service_tiers": null in the generated Codex manifest. Codex decodes service_tiers as a required array and rejects the whole manifest, so the client loses model metadata for every model. Fall back to [] for conflicting service_tiers (do not advertise any tier that is not shared by all peers), and normalize an explicit null to [] when applying capabilities. Co-Authored-By: Claude Opus 5.5 --- .../service/openai_codex_model_metadata.go | 8 ++++ .../openai_codex_model_metadata_test.go | 37 +++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/backend/internal/service/openai_codex_model_metadata.go b/backend/internal/service/openai_codex_model_metadata.go index b757908cddc9..96e0b36de63a 100644 --- a/backend/internal/service/openai_codex_model_metadata.go +++ b/backend/internal/service/openai_codex_model_metadata.go @@ -21,6 +21,11 @@ func applyCodexToolCapabilities(dst, src map[string]json.RawMessage, overwrite b if len(value) == 0 { continue } + // Codex decodes service_tiers as a required array and rejects the whole + // models manifest on null, so an undeclared tier list is advertised as []. + if field == "service_tiers" && bytes.Equal(value, []byte("null")) { + value = []byte("[]") + } // These Codex fields are nullable booleans or strings, never arbitrary objects. if !bytes.Equal(value, []byte("null")) { if field == "service_tiers" { @@ -280,6 +285,9 @@ func intersectUpstreamModelMetadata(modelID string, candidates []UpstreamModelMe fallback := json.RawMessage("null") if field == "supports_search_tool" || field == "use_responses_lite" { fallback = json.RawMessage("false") + } else if field == "service_tiers" { + // Conflicting peers must not advertise any tier, and Codex requires an array. + fallback = json.RawMessage("[]") } result.CodexToolCapabilities[field] = fallback } diff --git a/backend/internal/service/openai_codex_model_metadata_test.go b/backend/internal/service/openai_codex_model_metadata_test.go index f0d35b56cebc..f32407bbf0aa 100644 --- a/backend/internal/service/openai_codex_model_metadata_test.go +++ b/backend/internal/service/openai_codex_model_metadata_test.go @@ -108,6 +108,43 @@ func TestAstraCodexToolCapabilitiesUseAccountScopeAndSharedDeclarations(t *testi require.Equal(t, "3000", model["comp_hash"]) } +// Scenario: Codex rejects the whole models manifest when service_tiers is null, +// so conflicting or null tier declarations must be advertised as an empty array. +func TestCodexToolCapabilitiesNeverAdvertiseNullServiceTiers(t *testing.T) { + newAccount := func(id int64, tiers string) Account { + account := Account{ID: id, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{ + "base_url": "https://relay.example/v1", "model_mapping": map[string]any{"gpt-6-sol": "gpt-6-sol"}, + }} + if tiers != "" { + account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{ + "gpt-6-sol": {CodexToolCapabilities: map[string]json.RawMessage{"service_tiers": json.RawMessage(tiers)}}, + }}) + } + return account + } + for _, tt := range []struct { + name string + accounts []Account + }{ + {"conflicting peer tiers", []Account{newAccount(1, `[{"id":"priority"}]`), newAccount(2, `[]`)}}, + {"undeclared peer tiers", []Account{newAccount(1, `[{"id":"priority"}]`), newAccount(2, "")}}, + {"explicit null tiers", []Account{newAccount(1, "null")}}, + } { + t.Run(tt.name, func(t *testing.T) { + body, err := buildCodexModelsManifestForAccounts(PlatformOpenAI, []string{"gpt-6-sol"}, tt.accounts, nil, nil, true) + require.NoError(t, err) + model := decodeCodexManifestModels(t, body)[0] + require.Equal(t, []any{}, model["service_tiers"]) + }) + } + + merged := intersectUpstreamModelMetadata("gpt-6-sol", []UpstreamModelMetadata{ + {CodexToolCapabilities: map[string]json.RawMessage{"service_tiers": json.RawMessage(`[{"id":"priority"}]`)}}, + {CodexToolCapabilities: map[string]json.RawMessage{"service_tiers": json.RawMessage(`[]`)}}, + }) + require.JSONEq(t, `[]`, string(merged.CodexToolCapabilities["service_tiers"])) +} + func TestAstraCodexToolCapabilitiesPreserveLiveNullAndFalse(t *testing.T) { account := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{"base_url": "https://api.openai.com/v1"}} From dd7ee217034569a32bbbf10eb3a628196d9ea2fb Mon Sep 17 00:00:00 2001 From: Kingsley <10992364+kingsleydon@users.noreply.github.com> Date: Fri, 2 Oct 2026 09:47:26 +0000 Subject: [PATCH 30/88] style(codex): use tagged switch for capability conflict fallback Co-Authored-By: Claude Opus 5.5 --- backend/internal/service/openai_codex_model_metadata.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/backend/internal/service/openai_codex_model_metadata.go b/backend/internal/service/openai_codex_model_metadata.go index 96e0b36de63a..6eda29fbb955 100644 --- a/backend/internal/service/openai_codex_model_metadata.go +++ b/backend/internal/service/openai_codex_model_metadata.go @@ -283,9 +283,10 @@ func intersectUpstreamModelMetadata(modelID string, candidates []UpstreamModelMe result.CodexToolCapabilities[field] = value } else if declared { fallback := json.RawMessage("null") - if field == "supports_search_tool" || field == "use_responses_lite" { + switch field { + case "supports_search_tool", "use_responses_lite": fallback = json.RawMessage("false") - } else if field == "service_tiers" { + case "service_tiers": // Conflicting peers must not advertise any tier, and Codex requires an array. fallback = json.RawMessage("[]") } From 18380398d587e17a0653a8c8a23585c53d56e380 Mon Sep 17 00:00:00 2001 From: Kingsley <10992364+kingsleydon@users.noreply.github.com> Date: Fri, 2 Oct 2026 10:48:40 +0000 Subject: [PATCH 31/88] chore(codex): drop added comments Co-Authored-By: Claude Opus 5.5 --- backend/internal/service/openai_codex_model_metadata.go | 3 --- backend/internal/service/openai_codex_model_metadata_test.go | 2 -- 2 files changed, 5 deletions(-) diff --git a/backend/internal/service/openai_codex_model_metadata.go b/backend/internal/service/openai_codex_model_metadata.go index 6eda29fbb955..69e452b751ce 100644 --- a/backend/internal/service/openai_codex_model_metadata.go +++ b/backend/internal/service/openai_codex_model_metadata.go @@ -21,8 +21,6 @@ func applyCodexToolCapabilities(dst, src map[string]json.RawMessage, overwrite b if len(value) == 0 { continue } - // Codex decodes service_tiers as a required array and rejects the whole - // models manifest on null, so an undeclared tier list is advertised as []. if field == "service_tiers" && bytes.Equal(value, []byte("null")) { value = []byte("[]") } @@ -287,7 +285,6 @@ func intersectUpstreamModelMetadata(modelID string, candidates []UpstreamModelMe case "supports_search_tool", "use_responses_lite": fallback = json.RawMessage("false") case "service_tiers": - // Conflicting peers must not advertise any tier, and Codex requires an array. fallback = json.RawMessage("[]") } result.CodexToolCapabilities[field] = fallback diff --git a/backend/internal/service/openai_codex_model_metadata_test.go b/backend/internal/service/openai_codex_model_metadata_test.go index f32407bbf0aa..16ed878e90be 100644 --- a/backend/internal/service/openai_codex_model_metadata_test.go +++ b/backend/internal/service/openai_codex_model_metadata_test.go @@ -108,8 +108,6 @@ func TestAstraCodexToolCapabilitiesUseAccountScopeAndSharedDeclarations(t *testi require.Equal(t, "3000", model["comp_hash"]) } -// Scenario: Codex rejects the whole models manifest when service_tiers is null, -// so conflicting or null tier declarations must be advertised as an empty array. func TestCodexToolCapabilitiesNeverAdvertiseNullServiceTiers(t *testing.T) { newAccount := func(id int64, tiers string) Account { account := Account{ID: id, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{ From 4b6ed15a8b79db6c26be31fd4c20c80b8978d8d0 Mon Sep 17 00:00:00 2001 From: "chihao.ou" Date: Fri, 2 Oct 2026 22:20:41 +0800 Subject: [PATCH 32/88] fix(custom-pages): ignore obsolete Markdown responses --- frontend/src/views/user/CustomPageView.vue | 21 +++- .../__tests__/CustomPageView.race.spec.ts | 96 +++++++++++++++++++ 2 files changed, 112 insertions(+), 5 deletions(-) create mode 100644 frontend/src/views/user/__tests__/CustomPageView.race.spec.ts diff --git a/frontend/src/views/user/CustomPageView.vue b/frontend/src/views/user/CustomPageView.vue index 910b6dafa980..701e5931696b 100644 --- a/frontend/src/views/user/CustomPageView.vue +++ b/frontend/src/views/user/CustomPageView.vue @@ -160,6 +160,7 @@ const tocItems = ref([]) const tocVisible = ref(typeof window !== 'undefined' ? window.innerWidth > 768 : true) const activeHeadingId = ref('') let themeObserver: MutationObserver | null = null +let markdownRequestVersion = 0 const embedShell = ref(null) const openButton = ref(null) @@ -290,6 +291,7 @@ function buildPageImageUrl(slug: string, src: string): string { } async function fetchAndRenderMarkdown(slug: string) { + const version = ++markdownRequestVersion loading.value = true tocItems.value = [] activeHeadingId.value = '' @@ -297,11 +299,13 @@ async function fetchAndRenderMarkdown(slug: string) { const resp = await fetch(buildApiUrl(`/pages/${encodeURIComponent(slug)}`), { headers: authStore.token ? { Authorization: `Bearer ${authStore.token}` } : {}, }) + if (version !== markdownRequestVersion) return if (!resp.ok) { renderedHtml.value = `

${t('common.pageNotFound')}

` return } let raw = await resp.text() + if (version !== markdownRequestVersion) return raw = raw.replace( /!\[([^\]]*)\]\(([^)]+)\)/g, @@ -331,12 +335,16 @@ async function fetchAndRenderMarkdown(slug: string) { renderedHtml.value = withIds tocItems.value = toc } catch { - renderedHtml.value = '

Failed to load page

' + if (version === markdownRequestVersion) { + renderedHtml.value = '

Failed to load page

' + } } finally { - loading.value = false - await nextTick() - await nextTick() - injectCopyButtons() + if (version === markdownRequestVersion) { + loading.value = false + await nextTick() + await nextTick() + if (version === markdownRequestVersion) injectCopyButtons() + } } } @@ -406,6 +414,8 @@ watch(markdownSlug, (slug) => { if (slug) { fetchAndRenderMarkdown(slug) } else { + markdownRequestVersion++ + loading.value = false renderedHtml.value = '' tocItems.value = [] } @@ -434,6 +444,7 @@ onMounted(async () => { }) onUnmounted(() => { + markdownRequestVersion++ if (themeObserver) { themeObserver.disconnect() themeObserver = null diff --git a/frontend/src/views/user/__tests__/CustomPageView.race.spec.ts b/frontend/src/views/user/__tests__/CustomPageView.race.spec.ts new file mode 100644 index 000000000000..02d9a2478211 --- /dev/null +++ b/frontend/src/views/user/__tests__/CustomPageView.race.spec.ts @@ -0,0 +1,96 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils' +import { reactive, nextTick } from 'vue' +import CustomPageView from '../CustomPageView.vue' + +const route = reactive({ params: { id: 'old' } }) +const fetchPage = vi.fn() +vi.mock('vue-router', () => ({ useRoute: () => route })) +vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key: string) => key, locale: { value: 'en' } }) })) +vi.mock('@/stores', () => ({ useAppStore: () => ({ + publicSettingsLoaded: true, + cachedPublicSettings: { custom_menu_items: [ + { id: 'old', url: 'md:old' }, { id: 'new', url: 'md:new' }, + { id: 'external', url: 'https://example.com/docs' } + ] } +}) })) +vi.mock('@/stores/auth', () => ({ useAuthStore: () => ({ isAdmin: false, user: { id: 1 }, token: 'test' }) })) +vi.mock('@/stores/adminSettings', () => ({ useAdminSettingsStore: () => ({ customMenuItems: [] }) })) +vi.mock('@/api/client', () => ({ buildApiUrl: (path: string) => `/api/v1${path}` })) +vi.mock('@/components/layout/AppLayout.vue', () => ({ default: { template: '
' } })) +enableAutoUnmount(afterEach) + +function deferred() { + let resolve!: (value: T) => void + let reject!: (error: Error) => void + const promise = new Promise((yes, no) => { resolve = yes; reject = no }) + return { promise, resolve, reject } +} +const response = (markdown: string) => ({ ok: true, text: async () => markdown }) as Response +const mountPage = () => mount(CustomPageView, { global: { stubs: { Icon: true } } }) + +beforeEach(() => { + route.params.id = 'old' + fetchPage.mockReset() + vi.stubGlobal('fetch', fetchPage) + vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} }) +}) +afterEach(() => vi.unstubAllGlobals()) + +describe('custom Markdown page request ordering', () => { + it.each(['success', 'http error', 'network error'])('ignores an old %s after switching pages', async (outcome) => { + const old = deferred() + fetchPage.mockReturnValueOnce(old.promise).mockResolvedValueOnce(response('# New page')) + const wrapper = mountPage() + route.params.id = 'new' + await flushPromises() + expect(wrapper.get('.markdown-page-content h1').text()).toBe('New page') + if (outcome === 'network error') old.reject(new Error('offline')) + else old.resolve(outcome === 'http error' ? { ok: false } as Response : response('# Old page')) + await flushPromises() + expect(wrapper.get('.markdown-page-content h1').text()).toBe('New page') + expect(wrapper.get('.toc-item').text()).toBe('New page') + }) + + it('ignores an old body that finishes reading after the new page', async () => { + const body = deferred() + fetchPage.mockResolvedValueOnce({ ok: true, text: () => body.promise }) + .mockResolvedValueOnce(response('# New page')) + const wrapper = mountPage() + await flushPromises() + route.params.id = 'new' + await flushPromises() + body.resolve('# Old page') + await flushPromises() + expect(wrapper.get('.markdown-page-content h1').text()).toBe('New page') + }) + + it('keeps the current loading state when the old request completes', async () => { + const old = deferred() + const current = deferred() + fetchPage.mockReturnValueOnce(old.promise).mockReturnValueOnce(current.promise) + const wrapper = mountPage() + route.params.id = 'new' + await nextTick() + old.resolve(response('# Old page')) + await flushPromises() + expect(wrapper.find('.animate-spin').exists()).toBe(true) + expect(wrapper.find('.markdown-page-content').exists()).toBe(false) + current.resolve(response('# New page')) + await flushPromises() + expect(wrapper.get('.markdown-page-content h1').text()).toBe('New page') + }) + + it('shows an embedded page immediately after leaving a pending Markdown page', async () => { + const old = deferred() + fetchPage.mockReturnValueOnce(old.promise) + const wrapper = mountPage() + route.params.id = 'external' + await nextTick() + expect(wrapper.get('iframe').attributes('src')).toContain('https://example.com/docs') + old.resolve(response('# Old page')) + await flushPromises() + expect(wrapper.find('.markdown-page-content').exists()).toBe(false) + expect(wrapper.get('iframe').attributes('src')).toContain('https://example.com/docs') + }) +}) From 1c996fe301d761beb1b443a383cfe3d76abcd87b Mon Sep 17 00:00:00 2001 From: "chihao.ou" Date: Fri, 2 Oct 2026 22:21:02 +0800 Subject: [PATCH 33/88] fix(payment): stop late Stripe callbacks after leaving the page --- frontend/src/views/user/StripePaymentView.vue | 7 +- .../user/__tests__/StripePaymentView.spec.ts | 66 ++++++++++++++++++- 2 files changed, 69 insertions(+), 4 deletions(-) diff --git a/frontend/src/views/user/StripePaymentView.vue b/frontend/src/views/user/StripePaymentView.vue index 840eea912ac1..b845cfac52b7 100644 --- a/frontend/src/views/user/StripePaymentView.vue +++ b/frontend/src/views/user/StripePaymentView.vue @@ -129,6 +129,7 @@ const wechatQrUrl = ref('') const redirecting = ref(false) const showPaymentElement = ref(false) +let disposed = false let stripeInstance: Stripe | null = null let elementsInstance: StripeElements | null = null let redirectTimer: ReturnType | null = null @@ -167,6 +168,7 @@ onMounted(async () => { const { loadStripe } = await import('@stripe/stripe-js/pure') const stripe = await loadStripe(publishableKey) + if (disposed) return if (!stripe) { initError.value = t('payment.stripeLoadFailed'); return } stripeInstance = stripe @@ -241,6 +243,7 @@ async function confirmWechatPay(stripe: Stripe, clientSecret: string) { } function mountPaymentElement(stripe: Stripe, clientSecret: string) { + if (disposed) return const isDark = document.documentElement.classList.contains('dark') const elements = stripe.elements({ clientSecret, @@ -284,7 +287,7 @@ let pollTimer: ReturnType | null = null function startPolling() { const orderId = Number(route.query.order_id) - if (!orderId) return + if (!orderId || disposed) return pollTimer = setInterval(async () => { const o = await paymentStore.pollOrderStatus(orderId) if (!o) return @@ -298,6 +301,7 @@ function startPolling() { } function scheduleClose() { + if (disposed) return if (window.opener) { redirectTimer = setTimeout(() => { window.close() }, 2000) } else { @@ -308,6 +312,7 @@ function scheduleClose() { } onUnmounted(() => { + disposed = true if (redirectTimer) clearTimeout(redirectTimer) if (pollTimer) clearInterval(pollTimer) }) diff --git a/frontend/src/views/user/__tests__/StripePaymentView.spec.ts b/frontend/src/views/user/__tests__/StripePaymentView.spec.ts index 9b672048b8cd..0d85126a3f9a 100644 --- a/frontend/src/views/user/__tests__/StripePaymentView.spec.ts +++ b/frontend/src/views/user/__tests__/StripePaymentView.spec.ts @@ -1,5 +1,8 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { flushPromises, shallowMount } from '@vue/test-utils' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { enableAutoUnmount, flushPromises, shallowMount } from '@vue/test-utils' + +enableAutoUnmount(afterEach) +afterEach(() => { vi.clearAllTimers(); vi.useRealTimers() }) const routeState = vi.hoisted(() => ({ query: {} as Record, @@ -101,7 +104,7 @@ describe('StripePaymentView', () => { client_secret: 'pi_secret_42', } routerPush.mockReset() - getOrder.mockReset() + getOrder.mockReset().mockResolvedValue({ data: orderFactory() }) paymentStore.config = { stripe_publishable_key: 'pk_test' } paymentStore.fetchConfig.mockReset().mockResolvedValue(undefined) paymentStore.pollOrderStatus.mockReset() @@ -118,6 +121,63 @@ describe('StripePaymentView', () => { window.localStorage.clear() }) + it.each(['alipay', 'wechat_pay', ''])('does not start %s payment after leaving during SDK loading', async (method) => { + routeState.query.method = method + let resolveSDK!: (value: typeof stripeInstance) => void + loadStripe.mockReturnValueOnce(new Promise((resolve) => { resolveSDK = resolve })) + const wrapper = mountView() + await flushPromises() + await flushPromises() + expect(loadStripe).toHaveBeenCalledTimes(1) + wrapper.unmount() + resolveSDK(stripeInstance) + await flushPromises() + await flushPromises() + expect(stripeInstance.confirmAlipayPayment).not.toHaveBeenCalled() + expect(stripeInstance.confirmWechatPayPayment).not.toHaveBeenCalled() + expect(stripeInstance.elements).not.toHaveBeenCalled() + }) + + it('does not restart polling when a WeChat QR result arrives after leaving', async () => { + vi.useFakeTimers() + routeState.query.method = 'wechat_pay' + let resolvePayment!: (value: unknown) => void + stripeInstance.confirmWechatPayPayment.mockReturnValueOnce(new Promise((resolve) => { resolvePayment = resolve })) + const wrapper = mountView() + await flushPromises() + await flushPromises() + expect(stripeInstance.confirmWechatPayPayment).toHaveBeenCalledTimes(1) + wrapper.unmount() + resolvePayment({ paymentIntent: { status: 'requires_action', next_action: { + wechat_pay_display_qr_code: { image_data_url: 'data:image/png;base64,test' } + } } }) + await flushPromises() + await vi.advanceTimersByTimeAsync(3000) + expect(paymentStore.pollOrderStatus).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) + }) + + it.each([false, true])('handles a completed poll with page unmounted=%s', async (unmounted) => { + vi.useFakeTimers() + routeState.query.method = 'wechat_pay' + stripeInstance.confirmWechatPayPayment.mockResolvedValueOnce({ paymentIntent: { + status: 'requires_action', next_action: { wechat_pay_display_qr_code: { image_data_url: 'data:image/png;base64,test' } } + } }) + let resolvePoll!: (value: PaymentOrder) => void + paymentStore.pollOrderStatus.mockReturnValueOnce(new Promise((resolve) => { resolvePoll = resolve })) + const wrapper = mountView() + await flushPromises() + await flushPromises() + await vi.advanceTimersByTimeAsync(3000) + expect(paymentStore.pollOrderStatus).toHaveBeenCalledTimes(1) + if (unmounted) wrapper.unmount() + resolvePoll(orderFactory({ status: 'PAID' })) + await flushPromises() + await vi.advanceTimersByTimeAsync(2000) + expect(routerPush).toHaveBeenCalledTimes(unmounted ? 0 : 1) + expect(vi.getTimerCount()).toBe(0) + }) + it('本地恢复快照缺失时使用订单接口返回的 Stripe 币种展示金额', async () => { getOrder.mockResolvedValue({ data: orderFactory({ currency: 'HKD', pay_amount: 103 }), From abdfbb704cef4f0d6bc4e5fec416d063806753a3 Mon Sep 17 00:00:00 2001 From: Neo Tan <10399087+neotan@users.noreply.github.com> Date: Fri, 2 Oct 2026 11:34:20 -0400 Subject: [PATCH 34/88] fix(web): bypass bare API aliases from embedded frontend fallback The embedded frontend middleware runs before the gateway routes and only let a few root aliases through. Bare aliases such as /chat/completions, /embeddings, /messages/count_tokens and /models/:model were answered with index.html and HTTP 200 instead of reaching the API handlers. Add every root alias registered in routes/gateway.go to the bypass list. This also covers POST /videos, which the /videos/ prefix did not match. Fixes #7670 --- backend/internal/web/embed_on.go | 16 ++++++++++++ backend/internal/web/embed_test.go | 40 ++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/backend/internal/web/embed_on.go b/backend/internal/web/embed_on.go index 516ac88dc081..d16b1c4d8015 100644 --- a/backend/internal/web/embed_on.go +++ b/backend/internal/web/embed_on.go @@ -362,10 +362,26 @@ func shouldBypassEmbeddedFrontend(path string) bool { strings.HasPrefix(trimmed, "/setup/") || trimmed == "/health" || trimmed == "/models" || + strings.HasPrefix(trimmed, "/models/") || trimmed == "/responses" || strings.HasPrefix(trimmed, "/responses/") || + trimmed == "/chat/completions" || + trimmed == "/embeddings" || + trimmed == "/messages/count_tokens" || trimmed == "/alpha/search" || + trimmed == "/web_search" || + trimmed == "/x_search" || + trimmed == "/tts" || + trimmed == "/stt" || + trimmed == "/realtime" || + trimmed == "/custom-voices" || + strings.HasPrefix(trimmed, "/custom-voices/") || + trimmed == "/contents/generations/tasks" || + strings.HasPrefix(trimmed, "/contents/generations/tasks/") || + trimmed == "/v3/contents/generations/tasks" || + strings.HasPrefix(trimmed, "/v3/contents/generations/tasks/") || strings.HasPrefix(trimmed, "/images/") || + trimmed == "/videos" || strings.HasPrefix(trimmed, "/videos/") } diff --git a/backend/internal/web/embed_test.go b/backend/internal/web/embed_test.go index 3a9bc1151a9f..dc47269ff71d 100644 --- a/backend/internal/web/embed_test.go +++ b/backend/internal/web/embed_test.go @@ -530,6 +530,9 @@ func TestFrontendServer_Middleware(t *testing.T) { "/health", "/responses", "/responses/compact", + "/chat/completions", + "/models/gpt-5.5", + "/v3/contents/generations/tasks/task-123", } for _, path := range apiPaths { @@ -689,6 +692,40 @@ func TestEmbeddedFrontendBypassesBareVideoAPIRoutes(t *testing.T) { } } +func TestEmbeddedFrontendBypassesBareAPIAliases(t *testing.T) { + for _, path := range []string{ + "/chat/completions", + "/embeddings", + "/messages/count_tokens", + "/models/gpt-5.5", + "/videos", + "/tts", + "/stt", + "/custom-voices", + "/custom-voices/voice-123", + "/custom-voices/voice-123/audio", + "/realtime", + "/web_search", + "/x_search", + "/contents/generations/tasks", + "/contents/generations/tasks/task-123", + "/v3/contents/generations/tasks", + "/v3/contents/generations/tasks/task-123", + } { + require.True(t, shouldBypassEmbeddedFrontend(path), "path=%s", path) + } + + for _, path := range []string{ + "/model-plaza", + "/custom/page-1", + "/monitor", + "/setup", + "/v3/other", + } { + require.False(t, shouldBypassEmbeddedFrontend(path), "path=%s", path) + } +} + func TestNewFrontendServer(t *testing.T) { t.Run("creates_server_successfully", func(t *testing.T) { provider := &mockSettingsProvider{ @@ -792,6 +829,9 @@ func TestServeEmbeddedFrontend(t *testing.T) { "/health", "/responses", "/responses/compact", + "/chat/completions", + "/models/gpt-5.5", + "/v3/contents/generations/tasks/task-123", } for _, path := range apiPaths { From 852f0f824fd7e7f038ac8a3649701bafbbf600b1 Mon Sep 17 00:00:00 2001 From: Achord Chan Date: Sat, 3 Oct 2026 10:34:06 +0800 Subject: [PATCH 35/88] fix(anthropic): preserve requested tool change beta headers --- backend/internal/pkg/claude/constants.go | 3 + .../service/gateway_tool_changes_beta_test.go | 63 +++++++++++++++++++ .../service/gateway_upstream_request.go | 20 +++--- 3 files changed, 79 insertions(+), 7 deletions(-) create mode 100644 backend/internal/service/gateway_tool_changes_beta_test.go diff --git a/backend/internal/pkg/claude/constants.go b/backend/internal/pkg/claude/constants.go index 6c2aeebba3a2..5e0f9fc27650 100644 --- a/backend/internal/pkg/claude/constants.go +++ b/backend/internal/pkg/claude/constants.go @@ -20,6 +20,9 @@ const ( BetaFastMode = "fast-mode-2026-02-01" // Legacy structured output compatibility; forwarded only when explicitly requested. BetaStructuredOutputs = "structured-outputs-2025-11-13" + // Tool-change compatibility tokens; never enabled without an explicit client request. + BetaMidConversationToolChanges = "mid-conversation-tool-changes-2026-07-01" + BetaInlineTools = "inline-tools-2026-09-15" // 新增(对齐官方 CLI 2.1.9x 以来的流量) BetaPromptCachingScope = "prompt-caching-scope-2026-01-05" diff --git a/backend/internal/service/gateway_tool_changes_beta_test.go b/backend/internal/service/gateway_tool_changes_beta_test.go new file mode 100644 index 000000000000..a7ffd6a79e28 --- /dev/null +++ b/backend/internal/service/gateway_tool_changes_beta_test.go @@ -0,0 +1,63 @@ +//go:build unit + +package service + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestBuildUpstreamRequestToolChangeBetas(t *testing.T) { + const mid = "mid-conversation-tool-changes-2026-07-01" + const inline = "inline-tools-2026-09-15" + for _, tc := range []struct { + name, header string + drop map[string]struct{} + wantMid, wantInline bool + }{ + {"both", mid + "," + inline, nil, true, true}, + {"mid only", mid, nil, true, false}, + {"inline only", inline, nil, false, true}, + {"absent", "", nil, false, false}, + {"unknown", inline + "-other", nil, false, false}, + {"duplicate", inline + ", " + inline + "," + mid, nil, true, true}, + {"filter inline", mid + "," + inline, map[string]struct{}{inline: {}}, true, false}, + {"filter mid", mid + "," + inline, map[string]struct{}{mid: {}}, false, true}, + } { + t.Run(tc.name, func(t *testing.T) { + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil) + c.Request.Header.Set("anthropic-beta", tc.header+",unrelated-beta") + if tc.drop != nil { + c.Set(betaPolicyFilterSetKey, tc.drop) + } + account := &Account{ID: 701, Platform: PlatformAnthropic, Type: AccountTypeOAuth, + Credentials: map[string]any{"access_token": "test-token"}, Status: StatusActive, Schedulable: true} + body := []byte(`{"model":"claude-opus-5-5","max_tokens":1024,"messages":[{"role":"user","content":[{"type":"tool_addition","tool":{"type":"tool_reference","tool_name":"lookup"}},{"type":"text","text":"Use lookup"}]}]}`) + svc := &GatewayService{cfg: &config.Config{}} + req, _, err := svc.buildUpstreamRequest(context.Background(), c, account, body, + "test-token", "oauth", "claude-opus-5-5", false, true) + require.NoError(t, err) + out := readUpstreamBodyForTest(t, req) + require.JSONEq(t, gjson.GetBytes(body, "messages").Raw, gjson.GetBytes(out, "messages").Raw) + header := getHeaderRaw(req.Header, "anthropic-beta") + require.Equal(t, tc.wantMid, containsBetaToken(header, mid)) + require.Equal(t, tc.wantInline, containsBetaToken(header, inline)) + require.False(t, containsBetaToken(header, "unrelated-beta")) + require.False(t, containsBetaToken(header, inline+"-other")) + require.True(t, containsBetaToken(header, "oauth-2025-04-20")) + if tc.wantInline { + require.Equal(t, 1, strings.Count(header, inline)) + } + }) + } +} diff --git a/backend/internal/service/gateway_upstream_request.go b/backend/internal/service/gateway_upstream_request.go index b6e91a52a87c..06ee09822ce1 100644 --- a/backend/internal/service/gateway_upstream_request.go +++ b/backend/internal/service/gateway_upstream_request.go @@ -545,14 +545,20 @@ func (s *GatewayService) computeFinalAnthropicBeta( if tokenType == "oauth" { if mimicClaudeCode { - // Keep the default beta set, with a narrow opt-in for legacy structured - // output requests. Unknown client betas remain excluded and policy drops - // still take precedence over this compatibility token. - incomingBeta := "" - if containsBetaToken(clientBeta, claude.BetaStructuredOutputs) { - incomingBeta = claude.BetaStructuredOutputs + // Preserve explicitly requested compatibility tokens so the forwarded + // body and headers agree. Unknown betas remain excluded; policy drops + // still take precedence. Missing companion tokens are not inferred. + var incomingBetas []string + for _, token := range []string{ + claude.BetaStructuredOutputs, + claude.BetaMidConversationToolChanges, + claude.BetaInlineTools, + } { + if containsBetaToken(clientBeta, token) { + incomingBetas = append(incomingBetas, token) + } } - return mergeAnthropicBetaDropping(claude.FullClaudeCodeMimicryBetas(), incomingBeta, effectiveDropSet), true + return mergeAnthropicBetaDropping(claude.FullClaudeCodeMimicryBetas(), strings.Join(incomingBetas, ","), effectiveDropSet), true } // 真 Claude Code 客户端透传路径 return stripBetaTokensWithSet(s.getBetaHeader(modelID, clientBeta), effectiveDropSet), true From abbe42ac44130c9edd65728ff4e3fae3c323b5a1 Mon Sep 17 00:00:00 2001 From: ShanYi-Hui Date: Sat, 3 Oct 2026 17:09:38 +0800 Subject: [PATCH 36/88] fix(profile): preserve email binding draft during user refresh --- Makefile | 1 + .../ProfileIdentityBindingsSection.vue | 27 ++- .../ProfileIdentityBindingsSection.spec.ts | 162 +++++++++++++++++- 3 files changed, 181 insertions(+), 9 deletions(-) diff --git a/Makefile b/Makefile index e205902e2f8a..9959342a0ab5 100644 --- a/Makefile +++ b/Makefile @@ -18,6 +18,7 @@ FRONTEND_CRITICAL_VITEST := \ src/views/user/__tests__/PaymentResultView.spec.ts \ src/views/user/__tests__/ChannelStatusView.mode.spec.ts \ src/components/user/profile/__tests__/ProfileInfoCard.spec.ts \ + src/components/user/profile/__tests__/ProfileIdentityBindingsSection.spec.ts \ src/views/admin/__tests__/SettingsView.spec.ts \ src/features/channel-monitor-v2/__tests__/designSystem.structure.spec.ts \ src/features/channel-monitor-v2/__tests__/monitorFormat.spec.ts \ diff --git a/frontend/src/components/user/profile/ProfileIdentityBindingsSection.vue b/frontend/src/components/user/profile/ProfileIdentityBindingsSection.vue index 8764c07e3aed..e15b112873e3 100644 --- a/frontend/src/components/user/profile/ProfileIdentityBindingsSection.vue +++ b/frontend/src/components/user/profile/ProfileIdentityBindingsSection.vue @@ -99,6 +99,7 @@ class="input" :placeholder="t('profile.authBindings.emailPlaceholder')" :disabled="isSendingEmailCode || isBindingEmail" + @input="isEmailBindingFormDirty = true" />