@@ -2205,6 +2370,7 @@ import {
resolveOpenAIWSModeFromExtra
} from '@/utils/openaiWsMode'
import {
+ fetchKiroDefaultMappings,
getPresetMappingsByPlatform,
commonErrorCodes,
buildModelMappingObject,
@@ -2233,11 +2399,13 @@ const baseUrlHint = computed(() => {
if (!props.account) return t('admin.accounts.baseUrlHint')
if (props.account.platform === 'openai') return t('admin.accounts.openai.baseUrlHint')
if (props.account.platform === 'gemini') return t('admin.accounts.gemini.baseUrlHint')
+ if (props.account.platform === 'kiro') return t('admin.accounts.kiro.baseUrlHint')
return t('admin.accounts.baseUrlHint')
})
const antigravityPresetMappings = computed(() => getPresetMappingsByPlatform('antigravity'))
const bedrockPresets = computed(() => getPresetMappingsByPlatform('bedrock'))
+const isKiroOAuthAccount = computed(() => props.account?.platform === 'kiro' && props.account?.type === 'oauth')
// Model mapping type
interface ModelMapping {
@@ -2295,6 +2463,21 @@ const getOpenAICompactModelMappingKey = createStableObjectKeyResolver
('edit-antigravity-model-mapping')
const getTempUnschedRuleKey = createStableObjectKeyResolver('edit-temp-unsched-rule')
+const applyKiroModelMappings = (entries: Array<[string, string]>) => {
+ modelRestrictionMode.value = 'mapping'
+ modelMappings.value = entries.map(([from, to]) => ({ from, to }))
+ allowedModels.value = []
+}
+
+const loadDefaultKiroModelMappings = () => {
+ fetchKiroDefaultMappings().then(mappings => {
+ if (!isKiroOAuthAccount.value) return
+ modelRestrictionMode.value = 'mapping'
+ modelMappings.value = mappings.map(({ from, to }) => ({ from, to }))
+ allowedModels.value = []
+ })
+}
+
const showMixedChannelWarning = ref(false)
const mixedChannelWarningDetails = ref<{ groupName: string; currentPlatform: string; otherPlatform: string } | null>(
null
@@ -2486,6 +2669,7 @@ const tempUnschedPresets = computed(() => [
const defaultBaseUrl = computed(() => {
if (props.account?.platform === 'openai') return 'https://api.openai.com'
if (props.account?.platform === 'gemini') return 'https://generativelanguage.googleapis.com'
+ if (props.account?.platform === 'kiro') return ''
return 'https://api.anthropic.com'
})
@@ -2709,6 +2893,8 @@ const syncFormFromAccount = (newAccount: Account | null) => {
? 'https://api.openai.com'
: newAccount.platform === 'gemini'
? 'https://generativelanguage.googleapis.com'
+ : newAccount.platform === 'kiro'
+ ? ''
: 'https://api.anthropic.com'
editBaseUrl.value = (credentials.base_url as string) || platformDefaultUrl
@@ -2717,20 +2903,35 @@ const syncFormFromAccount = (newAccount: Account | null) => {
if (existingMappings && typeof existingMappings === 'object') {
const entries = Object.entries(existingMappings)
- // Detect if this is whitelist mode (all from === to) or mapping mode
- const isWhitelistMode = entries.length > 0 && entries.every(([from, to]) => from === to)
-
- if (isWhitelistMode) {
- // Whitelist mode: populate allowedModels
- modelRestrictionMode.value = 'whitelist'
- allowedModels.value = entries.map(([from]) => from)
- modelMappings.value = []
- } else {
- // Mapping mode: populate modelMappings
+ if (newAccount.platform === 'kiro') {
modelRestrictionMode.value = 'mapping'
modelMappings.value = entries.map(([from, to]) => ({ from, to }))
allowedModels.value = []
+ } else {
+ // Detect if this is whitelist mode (all from === to) or mapping mode
+ const isWhitelistMode = entries.length > 0 && entries.every(([from, to]) => from === to)
+
+ if (isWhitelistMode) {
+ // Whitelist mode: populate allowedModels
+ modelRestrictionMode.value = 'whitelist'
+ allowedModels.value = entries.map(([from]) => from)
+ modelMappings.value = []
+ } else {
+ // Mapping mode: populate modelMappings
+ modelRestrictionMode.value = 'mapping'
+ modelMappings.value = entries.map(([from, to]) => ({ from, to }))
+ allowedModels.value = []
+ }
}
+ } else if (newAccount.platform === 'kiro') {
+ fetchKiroDefaultMappings().then(mappings => {
+ if (props.account?.id !== newAccount.id || props.account?.type !== 'apikey' || props.account?.platform !== 'kiro') {
+ return
+ }
+ modelRestrictionMode.value = 'mapping'
+ modelMappings.value = mappings.map(({ from, to }) => ({ from, to }))
+ allowedModels.value = []
+ })
} else {
// No mappings: default to whitelist mode with empty selection (allow all)
modelRestrictionMode.value = 'whitelist'
@@ -2785,15 +2986,18 @@ const syncFormFromAccount = (newAccount: Account | null) => {
const entries = Object.entries(existingMappings)
const isWhitelistMode = entries.length > 0 && entries.every(([from, to]) => from === to)
if (isWhitelistMode) {
+ // Whitelist mode: populate allowedModels
modelRestrictionMode.value = 'whitelist'
allowedModels.value = entries.map(([from]) => from)
modelMappings.value = []
} else {
+ // Mapping mode: populate modelMappings
modelRestrictionMode.value = 'mapping'
modelMappings.value = entries.map(([from, to]) => ({ from, to }))
allowedModels.value = []
}
} else {
+ // No mappings: default to whitelist mode with empty selection (allow all)
modelRestrictionMode.value = 'whitelist'
modelMappings.value = []
allowedModels.value = []
@@ -2835,8 +3039,16 @@ const syncFormFromAccount = (newAccount: Account | null) => {
: 'https://api.anthropic.com'
editBaseUrl.value = platformDefaultUrl
- // Load model mappings for OpenAI OAuth accounts
- if (newAccount.platform === 'openai' && newAccount.credentials) {
+ // Load model mappings for OpenAI/Kiro OAuth accounts
+ if (newAccount.platform === 'kiro' && newAccount.credentials) {
+ const oauthCredentials = newAccount.credentials as Record
+ const existingMappings = oauthCredentials.model_mapping as Record | undefined
+ if (existingMappings && typeof existingMappings === 'object' && Object.keys(existingMappings).length > 0) {
+ applyKiroModelMappings(Object.entries(existingMappings))
+ } else {
+ loadDefaultKiroModelMappings()
+ }
+ } else if (newAccount.platform === 'openai' && newAccount.credentials) {
const oauthCredentials = newAccount.credentials as Record
const existingMappings = oauthCredentials.model_mapping as Record | undefined
if (existingMappings && typeof existingMappings === 'object') {
@@ -2892,6 +3104,7 @@ watch(
{ immediate: true }
)
+
// Model mapping helpers
const addModelMapping = () => {
modelMappings.value.push({ from: '', to: '' })
@@ -3333,9 +3546,16 @@ const handleSubmit = async () => {
// For apikey type, handle credentials update
if (props.account.type === 'apikey') {
const currentCredentials = (props.account.credentials as Record) || {}
- const newBaseUrl = editBaseUrl.value.trim() || defaultBaseUrl.value
+ const newBaseUrl = props.account.platform === 'kiro'
+ ? editBaseUrl.value.trim()
+ : (editBaseUrl.value.trim() || defaultBaseUrl.value)
const shouldApplyModelMapping = !(props.account.platform === 'openai' && openaiPassthroughEnabled.value)
+ if (!newBaseUrl) {
+ appStore.showError(t('admin.accounts.upstream.pleaseEnterBaseUrl'))
+ return
+ }
+
// Always update credentials for apikey type to handle model mapping changes
const newCredentials: Record = {
...currentCredentials,
@@ -3356,7 +3576,11 @@ const handleSubmit = async () => {
// Add model mapping if configured(OpenAI 开启自动透传时保留现有映射,不再编辑)
if (shouldApplyModelMapping) {
- const modelMapping = buildModelMappingObject(modelRestrictionMode.value, allowedModels.value, modelMappings.value)
+ const modelMapping = buildModelMappingObject(
+ props.account.platform === 'kiro' ? 'mapping' : modelRestrictionMode.value,
+ props.account.platform === 'kiro' ? [] : allowedModels.value,
+ modelMappings.value
+ )
if (modelMapping) {
newCredentials.model_mapping = modelMapping
} else {
@@ -3494,7 +3718,7 @@ const handleSubmit = async () => {
}
// Model mapping
- const modelMapping = buildModelMappingObject(modelRestrictionMode.value, allowedModels.value, modelMappings.value)
+ const modelMapping = buildModelMappingObject('mapping', [], modelMappings.value)
if (modelMapping) {
newCredentials.model_mapping = modelMapping
} else {
@@ -3548,6 +3772,22 @@ const handleSubmit = async () => {
updatePayload.credentials = newCredentials
}
+ // Kiro OAuth: persist model mapping to credentials
+ if (props.account.platform === 'kiro' && props.account.type === 'oauth') {
+ const currentCredentials = (updatePayload.credentials as Record) ||
+ ((props.account.credentials as Record) || {})
+ const newCredentials: Record = { ...currentCredentials }
+
+ const modelMapping = buildModelMappingObject('mapping', [], modelMappings.value)
+ if (modelMapping) {
+ newCredentials.model_mapping = modelMapping
+ } else {
+ delete newCredentials.model_mapping
+ }
+
+ updatePayload.credentials = newCredentials
+ }
+
// Antigravity: persist model mapping to credentials (applies to all antigravity types)
// Antigravity 只支持映射模式
if (props.account.platform === 'antigravity') {
diff --git a/frontend/src/components/account/OAuthAuthorizationFlow.vue b/frontend/src/components/account/OAuthAuthorizationFlow.vue
index 9526e878..239609d6 100644
--- a/frontend/src/components/account/OAuthAuthorizationFlow.vue
+++ b/frontend/src/components/account/OAuthAuthorizationFlow.vue
@@ -696,6 +696,7 @@ const getOAuthKey = (key: string) => {
if (props.platform === 'openai') return `admin.accounts.oauth.openai.${key}`
if (props.platform === 'gemini') return `admin.accounts.oauth.gemini.${key}`
if (props.platform === 'antigravity') return `admin.accounts.oauth.antigravity.${key}`
+ if (props.platform === 'kiro') return `admin.accounts.oauth.kiro.${key}`
return `admin.accounts.oauth.${key}`
}
@@ -726,6 +727,8 @@ const sessionTokenInput = ref('')
const codexSessionInput = ref('')
const showHelpDialog = ref(false)
const oauthState = ref('')
+const oauthCallbackPath = ref('')
+const oauthLoginOption = ref('')
const projectId = ref('')
// Computed: show method selection when either cookie or refresh token option is enabled
@@ -765,10 +768,10 @@ watch(inputMethod, (newVal) => {
emit('update:inputMethod', newVal)
})
-// Auto-extract code from callback URL (OpenAI/Gemini/Antigravity)
+// Auto-extract code from callback URL (OpenAI/Gemini/Antigravity/Kiro)
// e.g., http://localhost:8085/callback?code=xxx...&state=...
watch(authCodeInput, (newVal) => {
- if (props.platform !== 'openai' && props.platform !== 'gemini' && props.platform !== 'antigravity') return
+ if (props.platform !== 'openai' && props.platform !== 'gemini' && props.platform !== 'antigravity' && props.platform !== 'kiro') return
const trimmed = newVal.trim()
// Check if it looks like a URL with code parameter
@@ -778,7 +781,11 @@ watch(authCodeInput, (newVal) => {
const url = new URL(trimmed)
const code = url.searchParams.get('code')
const stateParam = url.searchParams.get('state')
- if ((props.platform === 'openai' || props.platform === 'gemini' || props.platform === 'antigravity') && stateParam) {
+ if (props.platform === 'kiro') {
+ oauthCallbackPath.value = url.pathname || ''
+ oauthLoginOption.value = url.searchParams.get('login_option') || ''
+ }
+ if ((props.platform === 'openai' || props.platform === 'gemini' || props.platform === 'antigravity' || props.platform === 'kiro') && stateParam) {
oauthState.value = stateParam
}
if (code && code !== trimmed) {
@@ -789,7 +796,13 @@ watch(authCodeInput, (newVal) => {
// If URL parsing fails, try regex extraction
const match = trimmed.match(/[?&]code=([^&]+)/)
const stateMatch = trimmed.match(/[?&]state=([^&]+)/)
- if ((props.platform === 'openai' || props.platform === 'gemini' || props.platform === 'antigravity') && stateMatch && stateMatch[1]) {
+ if (props.platform === 'kiro') {
+ const pathMatch = trimmed.match(/^https?:\/\/[^/]+(\/[^?]*)/)
+ oauthCallbackPath.value = pathMatch?.[1] || oauthCallbackPath.value
+ const loginOptionMatch = trimmed.match(/[?&]login_option=([^&]+)/)
+ oauthLoginOption.value = loginOptionMatch?.[1] || oauthLoginOption.value
+ }
+ if ((props.platform === 'openai' || props.platform === 'gemini' || props.platform === 'antigravity' || props.platform === 'kiro') && stateMatch && stateMatch[1]) {
oauthState.value = stateMatch[1]
}
if (match && match[1] && match[1] !== trimmed) {
@@ -841,6 +854,8 @@ const handleImportCodexSession = () => {
defineExpose({
authCode: authCodeInput,
oauthState,
+ oauthCallbackPath,
+ oauthLoginOption,
projectId,
sessionKey: sessionKeyInput,
refreshToken: refreshTokenInput,
@@ -850,6 +865,8 @@ defineExpose({
reset: () => {
authCodeInput.value = ''
oauthState.value = ''
+ oauthCallbackPath.value = ''
+ oauthLoginOption.value = ''
projectId.value = ''
sessionKeyInput.value = ''
refreshTokenInput.value = ''
diff --git a/frontend/src/components/account/__tests__/AccountStatusIndicator.spec.ts b/frontend/src/components/account/__tests__/AccountStatusIndicator.spec.ts
index f758e6b0..ded857a1 100644
--- a/frontend/src/components/account/__tests__/AccountStatusIndicator.spec.ts
+++ b/frontend/src/components/account/__tests__/AccountStatusIndicator.spec.ts
@@ -13,6 +13,15 @@ vi.mock('vue-i18n', async () => {
}
})
+vi.mock('@/i18n', () => ({
+ i18n: {
+ global: {
+ t: (key: string) => key
+ }
+ },
+ getLocale: () => 'en'
+}))
+
function makeAccount(overrides: Partial): Account {
return {
id: 1,
@@ -35,6 +44,12 @@ function makeAccount(overrides: Partial): Account {
overload_until: null,
temp_unschedulable_until: null,
temp_unschedulable_reason: null,
+ kiro_quota_state: null,
+ kiro_quota_reason: null,
+ kiro_quota_reset_at: null,
+ kiro_runtime_state: null,
+ kiro_runtime_reason: null,
+ kiro_runtime_reset_at: null,
session_window_start: null,
session_window_end: null,
session_window_status: null,
@@ -159,4 +174,96 @@ describe('AccountStatusIndicator', () => {
// AICredits 积分耗尽状态应显示
expect(wrapper.text()).toContain('admin.accounts.status.creditsExhausted')
})
+
+ it('Kiro 运行时冷却在状态列复用限流展示', () => {
+ const wrapper = mount(AccountStatusIndicator, {
+ props: {
+ account: makeAccount({
+ id: 5,
+ name: 'kiro-cooldown',
+ platform: 'kiro',
+ kiro_runtime_state: 'cooldown',
+ kiro_runtime_reason: 'rate_limit_exceeded',
+ kiro_runtime_reset_at: '2099-03-15T00:00:00Z'
+ })
+ },
+ global: {
+ stubs: {
+ Icon: true
+ }
+ }
+ })
+
+ expect(wrapper.text()).toContain('admin.accounts.status.rateLimited')
+ expect(wrapper.text()).toContain('admin.accounts.status.rateLimitedAutoResume')
+ expect(wrapper.text()).toContain('429')
+ })
+
+ it('Kiro suspended 在状态列显示为 forbidden', () => {
+ const wrapper = mount(AccountStatusIndicator, {
+ props: {
+ account: makeAccount({
+ id: 6,
+ name: 'kiro-suspended',
+ platform: 'kiro',
+ kiro_runtime_state: 'suspended',
+ kiro_runtime_reason: 'account_suspended',
+ kiro_runtime_reset_at: '2099-03-15T00:00:00Z'
+ })
+ },
+ global: {
+ stubs: {
+ Icon: true
+ }
+ }
+ })
+
+ expect(wrapper.text()).toContain('admin.accounts.forbidden')
+ })
+
+ it('Kiro overage active 在状态列仍显示正常状态', () => {
+ const wrapper = mount(AccountStatusIndicator, {
+ props: {
+ account: makeAccount({
+ id: 7,
+ name: 'kiro-overage-active',
+ platform: 'kiro',
+ kiro_quota_state: 'overage_active',
+ kiro_quota_reason: 'overages_enabled',
+ kiro_quota_reset_at: '2099-03-15T00:00:00Z'
+ })
+ },
+ global: {
+ stubs: {
+ Icon: true
+ }
+ }
+ })
+
+ expect(wrapper.text()).toContain('admin.accounts.status.active')
+ expect(wrapper.text()).not.toContain('admin.accounts.status.overageActive')
+ })
+
+ it('Kiro overage exhausted 在状态列显示危险徽章', () => {
+ const wrapper = mount(AccountStatusIndicator, {
+ props: {
+ account: makeAccount({
+ id: 8,
+ name: 'kiro-overage-exhausted',
+ platform: 'kiro',
+ kiro_quota_state: 'overage_exhausted',
+ kiro_quota_reason: 'overage disabled after quota exhaustion',
+ kiro_quota_reset_at: '2099-03-15T00:00:00Z'
+ })
+ },
+ global: {
+ stubs: {
+ Icon: true
+ }
+ }
+ })
+
+ expect(wrapper.text()).toContain('admin.accounts.status.overageExhausted')
+ expect(wrapper.text()).toContain('admin.accounts.status.overageExhaustedUntil')
+ })
})
diff --git a/frontend/src/components/account/__tests__/AccountUsageCell.spec.ts b/frontend/src/components/account/__tests__/AccountUsageCell.spec.ts
index fa4104f6..5634a605 100644
--- a/frontend/src/components/account/__tests__/AccountUsageCell.spec.ts
+++ b/frontend/src/components/account/__tests__/AccountUsageCell.spec.ts
@@ -25,6 +25,15 @@ vi.mock('vue-i18n', async () => {
}
})
+vi.mock('@/i18n', () => ({
+ i18n: {
+ global: {
+ t: (key: string) => key
+ }
+ },
+ getLocale: () => 'en'
+}))
+
function makeAccount(overrides: Partial): Account {
return {
id: 1,
@@ -47,6 +56,12 @@ function makeAccount(overrides: Partial): Account {
overload_until: null,
temp_unschedulable_until: null,
temp_unschedulable_reason: null,
+ kiro_quota_state: null,
+ kiro_quota_reason: null,
+ kiro_quota_reset_at: null,
+ kiro_runtime_state: null,
+ kiro_runtime_reason: null,
+ kiro_runtime_reset_at: null,
session_window_start: null,
session_window_end: null,
session_window_status: null,
@@ -530,6 +545,234 @@ describe('AccountUsageCell', () => {
expect(wrapper.text()).toContain('7d|100|106540000')
})
+ it('Kiro OAuth 会用 passive source 拉取并展示 credits 额度', async () => {
+ const account = makeAccount({
+ id: 3001,
+ platform: 'kiro',
+ type: 'oauth',
+ extra: {},
+ credentials: {}
+ })
+
+ getUsage.mockResolvedValue({
+ source: 'passive',
+ kiro_subscription_name: 'KIRO PRO+',
+ kiro_overages_enabled: true,
+ kiro_credit: {
+ current_usage: 125,
+ usage_limit: 2000,
+ percentage_used: 6.25,
+ },
+ kiro_bonus: {
+ current_usage: 25,
+ usage_limit: 500,
+ percentage_used: 5,
+ days_remaining: 7,
+ },
+ kiro_overage: {
+ current_overages: 2,
+ overage_charges: 0.08,
+ currency_symbol: '$',
+ currency_code: 'USD',
+ },
+ kiro_reset_at: '2099-03-13T12:00:00Z',
+ })
+
+ const wrapper = mount(AccountUsageCell, {
+ props: {
+ account
+ },
+ global: {
+ stubs: {
+ UsageProgressBar: true,
+ AccountQuotaInfo: true
+ }
+ }
+ })
+
+ await flushPromises()
+
+ expect(getUsage).toHaveBeenCalledWith(3001, 'passive')
+ expect(wrapper.emitted('kiroUsageMeta')?.[0]).toEqual([
+ {
+ plan_type: 'KIRO PRO+',
+ kiro_overages_enabled: true
+ }
+ ])
+ expect(wrapper.text()).toContain('admin.accounts.usageWindow.kiroCredits')
+ expect(wrapper.text()).toContain('125 / 2.0K')
+ expect(wrapper.text()).toContain('admin.accounts.usageWindow.kiroBonus')
+ expect(wrapper.text()).toContain('25 / 500')
+ expect(wrapper.text()).toContain('admin.accounts.usageWindow.kiroDaysLeft')
+ expect(wrapper.text()).toContain('admin.accounts.usageWindow.kiroReset')
+ expect(wrapper.text()).toContain('admin.accounts.usageWindow.kiroOverage 2 ($0.08)')
+ })
+
+ it('Kiro OAuth 会展示运行时冷却状态', async () => {
+ getUsage.mockResolvedValue({
+ source: 'passive',
+ kiro_runtime_state: 'cooldown',
+ kiro_runtime_reason: 'rate_limit_exceeded',
+ kiro_runtime_reset_at: '2099-03-13T12:00:00Z',
+ kiro_credit: {
+ current_usage: 10,
+ usage_limit: 100,
+ percentage_used: 10,
+ },
+ })
+
+ const wrapper = mount(AccountUsageCell, {
+ props: {
+ account: makeAccount({
+ id: 3002,
+ platform: 'kiro',
+ type: 'oauth',
+ extra: {},
+ credentials: {}
+ })
+ },
+ global: {
+ stubs: {
+ UsageProgressBar: true,
+ AccountQuotaInfo: true
+ }
+ }
+ })
+
+ await flushPromises()
+
+ expect(wrapper.text()).toContain('admin.accounts.status.rateLimited')
+ expect(wrapper.text()).toContain('admin.accounts.status.rateLimitedUntil')
+ })
+
+ it('Kiro OAuth 会展示 overage active 与 exhausted 状态', async () => {
+ getUsage.mockResolvedValueOnce({
+ source: 'passive',
+ kiro_quota_state: 'overage_active',
+ kiro_quota_reason: 'overages_enabled',
+ kiro_quota_reset_at: '2099-03-13T12:00:00Z',
+ kiro_overages_enabled: true,
+ kiro_credit: {
+ current_usage: 2100,
+ usage_limit: 2000,
+ percentage_used: 100,
+ },
+ kiro_overage: {
+ current_overages: 3,
+ overage_charges: 0.12,
+ currency_symbol: '$',
+ },
+ })
+
+ const activeWrapper = mount(AccountUsageCell, {
+ props: {
+ account: makeAccount({
+ id: 3005,
+ platform: 'kiro',
+ type: 'oauth',
+ extra: {},
+ credentials: {}
+ })
+ },
+ global: {
+ stubs: {
+ UsageProgressBar: true,
+ AccountQuotaInfo: true
+ }
+ }
+ })
+
+ await flushPromises()
+ expect(activeWrapper.text()).toContain('admin.accounts.status.overageActive')
+ expect(activeWrapper.text()).not.toContain('admin.accounts.status.overageActiveUntil')
+
+ getUsage.mockResolvedValueOnce({
+ source: 'passive',
+ kiro_quota_state: 'overage_exhausted',
+ kiro_quota_reason: 'usage API error: overage exhausted',
+ kiro_quota_reset_at: '2099-03-13T12:00:00Z',
+ error: 'usage API error: kiro usage request failed (status 429): {"message":"overage exhausted"}',
+ })
+
+ const exhaustedWrapper = mount(AccountUsageCell, {
+ props: {
+ account: makeAccount({
+ id: 3006,
+ platform: 'kiro',
+ type: 'oauth',
+ extra: {},
+ credentials: {}
+ })
+ },
+ global: {
+ stubs: {
+ UsageProgressBar: true,
+ AccountQuotaInfo: true
+ }
+ }
+ })
+
+ await flushPromises()
+ expect(exhaustedWrapper.text()).toContain('admin.accounts.status.overageExhausted')
+ expect(exhaustedWrapper.text()).toContain('admin.accounts.status.overageExhaustedUntil')
+ })
+
+ it('Kiro OAuth 会展示 profile 异常和 usage forbidden 徽章', async () => {
+ getUsage.mockResolvedValueOnce({
+ source: 'passive',
+ error_code: 'forbidden',
+ error: 'usage API error: kiro usage request failed (status 400): {"message":"profileArn is required for this request."}',
+ })
+
+ const profileWrapper = mount(AccountUsageCell, {
+ props: {
+ account: makeAccount({
+ id: 3003,
+ platform: 'kiro',
+ type: 'oauth',
+ extra: {},
+ credentials: {}
+ })
+ },
+ global: {
+ stubs: {
+ UsageProgressBar: true,
+ AccountQuotaInfo: true
+ }
+ }
+ })
+
+ await flushPromises()
+ expect(profileWrapper.text()).toContain('admin.accounts.usageError')
+
+ getUsage.mockResolvedValueOnce({
+ source: 'passive',
+ error_code: 'forbidden',
+ error: 'usage API error: kiro usage request failed (status 403): {"message":"User is not authorized to access this feature."}',
+ })
+
+ const forbiddenWrapper = mount(AccountUsageCell, {
+ props: {
+ account: makeAccount({
+ id: 3004,
+ platform: 'kiro',
+ type: 'oauth',
+ extra: {},
+ credentials: {}
+ })
+ },
+ global: {
+ stubs: {
+ UsageProgressBar: true,
+ AccountQuotaInfo: true
+ }
+ }
+ })
+
+ await flushPromises()
+ expect(forbiddenWrapper.text()).toContain('admin.accounts.forbidden')
+ })
+
it('Key 账号会展示 today stats 徽章并带 A/U 提示', async () => {
const wrapper = mount(AccountUsageCell, {
props: {
diff --git a/frontend/src/components/account/__tests__/OAuthAuthorizationFlow.spec.ts b/frontend/src/components/account/__tests__/OAuthAuthorizationFlow.spec.ts
new file mode 100644
index 00000000..efba0ba1
--- /dev/null
+++ b/frontend/src/components/account/__tests__/OAuthAuthorizationFlow.spec.ts
@@ -0,0 +1,56 @@
+import { describe, expect, it, vi } from 'vitest'
+import { nextTick } from 'vue'
+import { mount } from '@vue/test-utils'
+
+vi.mock('@/stores/app', () => ({
+ useAppStore: () => ({
+ showSuccess: vi.fn(),
+ showError: vi.fn()
+ })
+}))
+
+vi.mock('@/composables/useClipboard', () => ({
+ useClipboard: () => ({
+ copied: { value: false },
+ copyToClipboard: vi.fn()
+ })
+}))
+
+vi.mock('vue-i18n', async () => {
+ const actual = await vi.importActual('vue-i18n')
+ return {
+ ...actual,
+ useI18n: () => ({
+ t: (key: string) => key
+ })
+ }
+})
+
+import OAuthAuthorizationFlow from '../OAuthAuthorizationFlow.vue'
+
+describe('OAuthAuthorizationFlow', () => {
+ it('extracts code, state, and callback metadata from a full Kiro callback URL', async () => {
+ const wrapper = mount(OAuthAuthorizationFlow, {
+ props: {
+ addMethod: 'oauth',
+ platform: 'kiro',
+ authUrl: 'https://example.com/authorize',
+ sessionId: 'session-1'
+ },
+ global: {
+ stubs: {
+ Icon: true
+ }
+ }
+ })
+
+ const textarea = wrapper.get('textarea')
+ await textarea.setValue('http://localhost:49153/oauth/callback?code=abc123&state=state456&login_option=github')
+ await nextTick()
+
+ expect((textarea.element as HTMLTextAreaElement).value).toBe('abc123')
+ expect((wrapper.vm as any).oauthState).toBe('state456')
+ expect((wrapper.vm as any).oauthCallbackPath).toBe('/oauth/callback')
+ expect((wrapper.vm as any).oauthLoginOption).toBe('github')
+ })
+})
diff --git a/frontend/src/components/admin/ErrorPassthroughRulesModal.vue b/frontend/src/components/admin/ErrorPassthroughRulesModal.vue
index 2ed6ded3..b4df5629 100644
--- a/frontend/src/components/admin/ErrorPassthroughRulesModal.vue
+++ b/frontend/src/components/admin/ErrorPassthroughRulesModal.vue
@@ -489,7 +489,8 @@ const platformOptions = [
{ value: 'anthropic', label: 'Anthropic' },
{ value: 'openai', label: 'OpenAI' },
{ value: 'gemini', label: 'Gemini' },
- { value: 'antigravity', label: 'Antigravity' }
+ { value: 'antigravity', label: 'Antigravity' },
+ { value: 'kiro', label: 'Kiro' }
]
// Load rules when dialog opens
diff --git a/frontend/src/components/admin/account/AccountTableFilters.vue b/frontend/src/components/admin/account/AccountTableFilters.vue
index b33dad84..358671ff 100644
--- a/frontend/src/components/admin/account/AccountTableFilters.vue
+++ b/frontend/src/components/admin/account/AccountTableFilters.vue
@@ -25,7 +25,7 @@ const updateType = (value: string | number | boolean | null) => { emit('update:f
const updateStatus = (value: string | number | boolean | null) => { emit('update:filters', { ...props.filters, status: value }) }
const updatePrivacyMode = (value: string | number | boolean | null) => { emit('update:filters', { ...props.filters, privacy_mode: value }) }
const updateGroup = (value: string | number | boolean | null) => { emit('update:filters', { ...props.filters, group: value }) }
-const pOpts = computed(() => [{ value: '', label: t('admin.accounts.allPlatforms') }, { value: 'anthropic', label: 'Anthropic' }, { value: 'openai', label: 'OpenAI' }, { value: 'gemini', label: 'Gemini' }, { value: 'antigravity', label: 'Antigravity' }])
+const pOpts = computed(() => [{ value: '', label: t('admin.accounts.allPlatforms') }, { value: 'anthropic', label: 'Anthropic' }, { value: 'openai', label: 'OpenAI' }, { value: 'gemini', label: 'Gemini' }, { value: 'antigravity', label: 'Antigravity' }, { value: 'kiro', label: 'Kiro' }])
const tOpts = computed(() => [{ value: '', label: t('admin.accounts.allTypes') }, { value: 'oauth', label: t('admin.accounts.oauthType') }, { value: 'setup-token', label: t('admin.accounts.setupToken') }, { value: 'apikey', label: t('admin.accounts.apiKey') }, { value: 'bedrock', label: 'AWS Bedrock' }])
const sOpts = computed(() => [{ value: '', label: t('admin.accounts.allStatus') }, { value: 'active', label: t('admin.accounts.status.active') }, { value: 'inactive', label: t('admin.accounts.status.inactive') }, { value: 'error', label: t('admin.accounts.status.error') }, { value: 'rate_limited', label: t('admin.accounts.status.rateLimited') }, { value: 'temp_unschedulable', label: t('admin.accounts.status.tempUnschedulable') }, { value: 'unschedulable', label: t('admin.accounts.status.unschedulable') }])
const privacyOpts = computed(() => [
diff --git a/frontend/src/components/admin/account/ReAuthAccountModal.vue b/frontend/src/components/admin/account/ReAuthAccountModal.vue
index 637d6011..c5d94de1 100644
--- a/frontend/src/components/admin/account/ReAuthAccountModal.vue
+++ b/frontend/src/components/admin/account/ReAuthAccountModal.vue
@@ -2,14 +2,11 @@
-
-
+
- {{
- account.name
- }}
+ {{ account.name }}
{{
isOpenAI
? t('admin.accounts.openaiAccount')
: isGemini
? t('admin.accounts.geminiAccount')
- : isAntigravity
- ? t('admin.accounts.antigravityAccount')
- : t('admin.accounts.claudeCodeAccount')
+ : isKiro
+ ? t('admin.accounts.kiroAccount')
+ : isAntigravity
+ ? t('admin.accounts.antigravityAccount')
+ : t('admin.accounts.claudeCodeAccount')
}}
-
-
{{ t('admin.accounts.oauth.gemini.oauthTypeLabel') }}
@@ -116,7 +109,187 @@
+
+
+ {{ t('admin.accounts.oauth.kiro.authModeTitle') }}
+
+
+
+
+
+
+
+
+
+ {{ t('admin.accounts.oauth.kiro.oauthSubtitle') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('admin.accounts.oauth.kiro.tokenJsonHint') }}
+
+
+
+
+
+
+
+
-
@@ -142,7 +314,16 @@
{{ t('common.cancel') }}
+
@@ -180,28 +357,29 @@