Commit 2b70d1d3 authored by IanShaw027's avatar IanShaw027
Browse files

merge upstream main into fix/bug-cleanup-main

parents b37afd68 00c08c57
ALTER TABLE groups
ADD COLUMN IF NOT EXISTS messages_dispatch_model_config JSONB NOT NULL DEFAULT '{}'::jsonb;
You are Codex, based on GPT-5. You are running as a coding agent in the Codex CLI on a user's computer.
{{ if .ExistingInstructions }}
{{ .ExistingInstructions }}
{{ end }}
...@@ -202,6 +202,32 @@ gateway: ...@@ -202,6 +202,32 @@ gateway:
# #
# 注意:开启后会影响所有客户端的行为(不仅限于 VS Code / Codex CLI),请谨慎开启。 # 注意:开启后会影响所有客户端的行为(不仅限于 VS Code / Codex CLI),请谨慎开启。
force_codex_cli: false force_codex_cli: false
# Optional: template file used to build the final top-level Codex `instructions`.
# 可选:用于构建最终 Codex 顶层 `instructions` 的模板文件路径。
#
# This is applied on the `/v1/messages -> Responses/Codex` conversion path,
# after Claude `system` has already been normalized into Codex `instructions`.
# 该模板作用于 `/v1/messages -> Responses/Codex` 转换链路,且发生在 Claude `system`
# 已经被归一化为 Codex `instructions` 之后。
#
# The template can reference:
# 模板可引用:
# - {{ .ExistingInstructions }} : converted client instructions/system
# - {{ .OriginalModel }} : original requested model
# - {{ .NormalizedModel }} : normalized routing model
# - {{ .BillingModel }} : billing model
# - {{ .UpstreamModel }} : final upstream model
#
# If you want to preserve client system prompts, keep {{ .ExistingInstructions }}
# somewhere in the template. If omitted, the template output fully replaces it.
# 如需保留客户端 system 提示词,请在模板中显式包含 {{ .ExistingInstructions }}。
# 若省略,则模板输出会完全覆盖它。
#
# Docker users can mount a host file to /app/data/codex-instructions.md.tmpl
# and point this field there.
# Docker 用户可将宿主机文件挂载到 /app/data/codex-instructions.md.tmpl,
# 然后把本字段指向该路径。
forced_codex_instructions_template_file: ""
# OpenAI 透传模式是否放行客户端超时头(如 x-stainless-timeout) # OpenAI 透传模式是否放行客户端超时头(如 x-stainless-timeout)
# 默认 false:过滤超时头,降低上游提前断流风险。 # 默认 false:过滤超时头,降低上游提前断流风险。
openai_passthrough_allow_timeout_headers: false openai_passthrough_allow_timeout_headers: false
...@@ -347,12 +373,6 @@ gateway: ...@@ -347,12 +373,6 @@ gateway:
# Enable batch load calculation for scheduling # Enable batch load calculation for scheduling
# 启用调度批量负载计算 # 启用调度批量负载计算
load_batch_enabled: true load_batch_enabled: true
# Snapshot bucket MGET chunk size
# 调度快照分桶读取时的 MGET 分块大小
snapshot_mget_chunk_size: 128
# Snapshot bucket write chunk size
# 调度快照重建写入时的分块大小
snapshot_write_chunk_size: 256
# Slot cleanup interval (duration) # Slot cleanup interval (duration)
# 并发槽位清理周期(时间段) # 并发槽位清理周期(时间段)
slot_cleanup_interval: 30s slot_cleanup_interval: 30s
...@@ -826,6 +846,46 @@ linuxdo_connect: ...@@ -826,6 +846,46 @@ linuxdo_connect:
userinfo_id_path: "" userinfo_id_path: ""
userinfo_username_path: "" userinfo_username_path: ""
# =============================================================================
# Generic OIDC OAuth Login (SSO)
# 通用 OIDC OAuth 登录(用于 Sub2API 用户登录)
# =============================================================================
oidc_connect:
enabled: false
provider_name: "OIDC"
client_id: ""
client_secret: ""
# 例如: "https://keycloak.example.com/realms/myrealm"
issuer_url: ""
# 可选: OIDC Discovery URL。为空时可手动填写 authorize/token/userinfo/jwks
discovery_url: ""
authorize_url: ""
token_url: ""
# 可选(仅补充 email/username,不用于 sub 可信绑定)
userinfo_url: ""
# validate_id_token=true 时必填
jwks_url: ""
scopes: "openid email profile"
# 示例: "https://your-domain.com/api/v1/auth/oauth/oidc/callback"
redirect_url: ""
# 安全提示:
# - 建议使用同源相对路径(以 / 开头),避免把 token 重定向到意外的第三方域名
# - 该地址不应包含 #fragment(本实现使用 URL fragment 传递 access_token)
frontend_redirect_url: "/auth/oidc/callback"
token_auth_method: "client_secret_post" # client_secret_post | client_secret_basic | none
# 注意:当 token_auth_method=none(public client)时,必须启用 PKCE
use_pkce: false
# 开启后强制校验 id_token 的签名和 claims(推荐)
validate_id_token: true
allowed_signing_algs: "RS256,ES256,PS256"
# 允许的时钟偏移(秒)
clock_skew_seconds: 120
# 若 Provider 返回 email_verified=false,是否拒绝登录
require_email_verified: false
userinfo_email_path: ""
userinfo_id_path: ""
userinfo_username_path: ""
# ============================================================================= # =============================================================================
# Default Settings # Default Settings
# 默认设置 # 默认设置
......
...@@ -31,6 +31,10 @@ services: ...@@ -31,6 +31,10 @@ services:
# Optional: Mount custom config.yaml (uncomment and create the file first) # Optional: Mount custom config.yaml (uncomment and create the file first)
# Copy config.example.yaml to config.yaml, modify it, then uncomment: # Copy config.example.yaml to config.yaml, modify it, then uncomment:
# - ./config.yaml:/app/data/config.yaml # - ./config.yaml:/app/data/config.yaml
# Optional: Mount a custom Codex instructions template file, then point
# gateway.forced_codex_instructions_template_file at /app/data/codex-instructions.md.tmpl
# in config.yaml.
# - ./codex-instructions.md.tmpl:/app/data/codex-instructions.md.tmpl:ro
environment: environment:
# ======================================================================= # =======================================================================
# Auto Setup (REQUIRED for Docker deployment) # Auto Setup (REQUIRED for Docker deployment)
...@@ -146,7 +150,17 @@ services: ...@@ -146,7 +150,17 @@ services:
networks: networks:
- sub2api-network - sub2api-network
healthcheck: healthcheck:
test: ["CMD", "wget", "-q", "-T", "5", "-O", "/dev/null", "http://localhost:8080/health"] test:
[
"CMD",
"wget",
"-q",
"-T",
"5",
"-O",
"/dev/null",
"http://localhost:8080/health",
]
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 3 retries: 3
...@@ -177,11 +191,17 @@ services: ...@@ -177,11 +191,17 @@ services:
networks: networks:
- sub2api-network - sub2api-network
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-sub2api} -d ${POSTGRES_DB:-sub2api}"] test:
[
"CMD-SHELL",
"pg_isready -U ${POSTGRES_USER:-sub2api} -d ${POSTGRES_DB:-sub2api}",
]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5
start_period: 10s start_period: 10s
ports:
- 5432:5432
# 注意:不暴露端口到宿主机,应用通过内部网络连接 # 注意:不暴露端口到宿主机,应用通过内部网络连接
# 如需调试,可临时添加:ports: ["127.0.0.1:5433:5432"] # 如需调试,可临时添加:ports: ["127.0.0.1:5433:5432"]
...@@ -217,7 +237,8 @@ services: ...@@ -217,7 +237,8 @@ services:
timeout: 5s timeout: 5s
retries: 5 retries: 5
start_period: 5s start_period: 5s
ports:
- 6379:6379
# ============================================================================= # =============================================================================
# Volumes # Volumes
# ============================================================================= # =============================================================================
......
...@@ -64,6 +64,30 @@ export interface SystemSettings { ...@@ -64,6 +64,30 @@ export interface SystemSettings {
linuxdo_connect_client_secret_configured: boolean linuxdo_connect_client_secret_configured: boolean
linuxdo_connect_redirect_url: string linuxdo_connect_redirect_url: string
// Generic OIDC OAuth settings
oidc_connect_enabled: boolean
oidc_connect_provider_name: string
oidc_connect_client_id: string
oidc_connect_client_secret_configured: boolean
oidc_connect_issuer_url: string
oidc_connect_discovery_url: string
oidc_connect_authorize_url: string
oidc_connect_token_url: string
oidc_connect_userinfo_url: string
oidc_connect_jwks_url: string
oidc_connect_scopes: string
oidc_connect_redirect_url: string
oidc_connect_frontend_redirect_url: string
oidc_connect_token_auth_method: string
oidc_connect_use_pkce: boolean
oidc_connect_validate_id_token: boolean
oidc_connect_allowed_signing_algs: string
oidc_connect_clock_skew_seconds: number
oidc_connect_require_email_verified: boolean
oidc_connect_userinfo_email_path: string
oidc_connect_userinfo_id_path: string
oidc_connect_userinfo_username_path: string
// Model fallback configuration // Model fallback configuration
enable_model_fallback: boolean enable_model_fallback: boolean
fallback_model_anthropic: string fallback_model_anthropic: string
...@@ -135,6 +159,28 @@ export interface UpdateSettingsRequest { ...@@ -135,6 +159,28 @@ export interface UpdateSettingsRequest {
linuxdo_connect_client_id?: string linuxdo_connect_client_id?: string
linuxdo_connect_client_secret?: string linuxdo_connect_client_secret?: string
linuxdo_connect_redirect_url?: string linuxdo_connect_redirect_url?: string
oidc_connect_enabled?: boolean
oidc_connect_provider_name?: string
oidc_connect_client_id?: string
oidc_connect_client_secret?: string
oidc_connect_issuer_url?: string
oidc_connect_discovery_url?: string
oidc_connect_authorize_url?: string
oidc_connect_token_url?: string
oidc_connect_userinfo_url?: string
oidc_connect_jwks_url?: string
oidc_connect_scopes?: string
oidc_connect_redirect_url?: string
oidc_connect_frontend_redirect_url?: string
oidc_connect_token_auth_method?: string
oidc_connect_use_pkce?: boolean
oidc_connect_validate_id_token?: boolean
oidc_connect_allowed_signing_algs?: string
oidc_connect_clock_skew_seconds?: number
oidc_connect_require_email_verified?: boolean
oidc_connect_userinfo_email_path?: string
oidc_connect_userinfo_id_path?: string
oidc_connect_userinfo_username_path?: string
enable_model_fallback?: boolean enable_model_fallback?: boolean
fallback_model_anthropic?: string fallback_model_anthropic?: string
fallback_model_openai?: string fallback_model_openai?: string
......
...@@ -357,6 +357,28 @@ export async function completeLinuxDoOAuthRegistration( ...@@ -357,6 +357,28 @@ export async function completeLinuxDoOAuthRegistration(
return data return data
} }
/**
* Complete OIDC OAuth registration by supplying an invitation code
* @param pendingOAuthToken - Short-lived JWT from the OAuth callback
* @param invitationCode - Invitation code entered by the user
* @returns Token pair on success
*/
export async function completeOIDCOAuthRegistration(
pendingOAuthToken: string,
invitationCode: string
): Promise<{ access_token: string; refresh_token: string; expires_in: number; token_type: string }> {
const { data } = await apiClient.post<{
access_token: string
refresh_token: string
expires_in: number
token_type: string
}>('/auth/oauth/oidc/complete-registration', {
pending_oauth_token: pendingOAuthToken,
invitation_code: invitationCode
})
return data
}
export const authAPI = { export const authAPI = {
login, login,
login2FA, login2FA,
...@@ -380,7 +402,8 @@ export const authAPI = { ...@@ -380,7 +402,8 @@ export const authAPI = {
resetPassword, resetPassword,
refreshToken, refreshToken,
revokeAllSessions, revokeAllSessions,
completeLinuxDoOAuthRegistration completeLinuxDoOAuthRegistration,
completeOIDCOAuthRegistration
} }
export default authAPI export default authAPI
...@@ -29,10 +29,10 @@ ...@@ -29,10 +29,10 @@
{{ t('auth.linuxdo.signIn') }} {{ t('auth.linuxdo.signIn') }}
</button> </button>
<div class="flex items-center gap-3"> <div v-if="showDivider" class="flex items-center gap-3">
<div class="h-px flex-1 bg-gray-200 dark:bg-dark-700"></div> <div class="h-px flex-1 bg-gray-200 dark:bg-dark-700"></div>
<span class="text-xs text-gray-500 dark:text-dark-400"> <span class="text-xs text-gray-500 dark:text-dark-400">
{{ t('auth.linuxdo.orContinue') }} {{ t('auth.oauthOrContinue') }}
</span> </span>
<div class="h-px flex-1 bg-gray-200 dark:bg-dark-700"></div> <div class="h-px flex-1 bg-gray-200 dark:bg-dark-700"></div>
</div> </div>
...@@ -43,9 +43,12 @@ ...@@ -43,9 +43,12 @@
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
defineProps<{ withDefaults(defineProps<{
disabled?: boolean disabled?: boolean
}>() showDivider?: boolean
}>(), {
showDivider: true
})
const route = useRoute() const route = useRoute()
const { t } = useI18n() const { t } = useI18n()
...@@ -58,4 +61,3 @@ function startLogin(): void { ...@@ -58,4 +61,3 @@ function startLogin(): void {
window.location.href = startURL window.location.href = startURL
} }
</script> </script>
<template>
<div class="space-y-4">
<button type="button" :disabled="disabled" class="btn btn-secondary w-full" @click="startLogin">
<span
class="mr-2 inline-flex h-5 w-5 items-center justify-center rounded-full bg-primary-100 text-xs font-semibold text-primary-700 dark:bg-primary-900/30 dark:text-primary-300"
>
{{ providerInitial }}
</span>
{{ t('auth.oidc.signIn', { providerName: normalizedProviderName }) }}
</button>
<div v-if="showDivider" class="flex items-center gap-3">
<div class="h-px flex-1 bg-gray-200 dark:bg-dark-700"></div>
<span class="text-xs text-gray-500 dark:text-dark-400">
{{ t('auth.oauthOrContinue') }}
</span>
<div class="h-px flex-1 bg-gray-200 dark:bg-dark-700"></div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
const props = withDefaults(defineProps<{
disabled?: boolean
providerName?: string
showDivider?: boolean
}>(), {
providerName: 'OIDC',
showDivider: true
})
const route = useRoute()
const { t } = useI18n()
const normalizedProviderName = computed(() => {
const name = props.providerName?.trim()
return name || 'OIDC'
})
const providerInitial = computed(() => normalizedProviderName.value.charAt(0).toUpperCase() || 'O')
function startLogin(): void {
const redirectTo = (route.query.redirect as string) || '/dashboard'
const apiBase = (import.meta.env.VITE_API_BASE_URL as string | undefined) || '/api/v1'
const normalized = apiBase.replace(/\/$/, '')
const startURL = `${normalized}/auth/oauth/oidc/start?redirect=${encodeURIComponent(redirectTo)}`
window.location.href = startURL
}
</script>
...@@ -428,6 +428,7 @@ export default { ...@@ -428,6 +428,7 @@ export default {
invitationCodeInvalid: 'Invalid or used invitation code', invitationCodeInvalid: 'Invalid or used invitation code',
invitationCodeValidating: 'Validating invitation code...', invitationCodeValidating: 'Validating invitation code...',
invitationCodeInvalidCannotRegister: 'Invalid invitation code. Please check and try again', invitationCodeInvalidCannotRegister: 'Invalid invitation code. Please check and try again',
oauthOrContinue: 'or continue with email',
linuxdo: { linuxdo: {
signIn: 'Continue with Linux.do', signIn: 'Continue with Linux.do',
orContinue: 'or continue with email', orContinue: 'or continue with email',
...@@ -442,6 +443,20 @@ export default { ...@@ -442,6 +443,20 @@ export default {
completing: 'Completing registration…', completing: 'Completing registration…',
completeRegistrationFailed: 'Registration failed. Please check your invitation code and try again.' completeRegistrationFailed: 'Registration failed. Please check your invitation code and try again.'
}, },
oidc: {
signIn: 'Continue with {providerName}',
callbackTitle: 'Signing you in with {providerName}',
callbackProcessing: 'Completing login with {providerName}, please wait...',
callbackHint: 'If you are not redirected automatically, go back to the login page and try again.',
callbackMissingToken: 'Missing login token, please try again.',
backToLogin: 'Back to Login',
invitationRequired:
'This {providerName} account is not yet registered. The site requires an invitation code — please enter one to complete registration.',
invalidPendingToken: 'The registration token has expired. Please sign in again.',
completeRegistration: 'Complete Registration',
completing: 'Completing registration…',
completeRegistrationFailed: 'Registration failed. Please check your invitation code and try again.'
},
oauth: { oauth: {
code: 'Code', code: 'Code',
state: 'State', state: 'State',
...@@ -4228,6 +4243,57 @@ export default { ...@@ -4228,6 +4243,57 @@ export default {
quickSetCopy: 'Generate & Copy (current site)', quickSetCopy: 'Generate & Copy (current site)',
redirectUrlSetAndCopied: 'Redirect URL generated and copied to clipboard' redirectUrlSetAndCopied: 'Redirect URL generated and copied to clipboard'
}, },
oidc: {
title: 'OIDC Login',
description: 'Configure a standard OIDC provider (for example Keycloak)',
enable: 'Enable OIDC Login',
enableHint: 'Show OIDC login on the login/register pages',
providerName: 'Provider Name',
providerNamePlaceholder: 'for example Keycloak',
clientId: 'Client ID',
clientIdPlaceholder: 'OIDC client id',
clientSecret: 'Client Secret',
clientSecretPlaceholder: '********',
clientSecretHint: 'Used by backend to exchange tokens (keep it secret)',
clientSecretConfiguredPlaceholder: '********',
clientSecretConfiguredHint: 'Secret configured. Leave empty to keep the current value.',
issuerUrl: 'Issuer URL',
issuerUrlPlaceholder: 'https://id.example.com/realms/main',
discoveryUrl: 'Discovery URL',
discoveryUrlPlaceholder: 'Optional, leave empty to auto-derive from issuer',
authorizeUrl: 'Authorize URL',
authorizeUrlPlaceholder: 'Optional, can be discovered automatically',
tokenUrl: 'Token URL',
tokenUrlPlaceholder: 'Optional, can be discovered automatically',
userinfoUrl: 'UserInfo URL',
userinfoUrlPlaceholder: 'Optional, can be discovered automatically',
jwksUrl: 'JWKS URL',
jwksUrlPlaceholder: 'Optional, required when strict ID token validation is enabled',
scopes: 'Scopes',
scopesPlaceholder: 'openid email profile',
scopesHint: 'Must include openid',
redirectUrl: 'Backend Redirect URL',
redirectUrlPlaceholder: 'https://your-domain.com/api/v1/auth/oauth/oidc/callback',
redirectUrlHint: 'Must match the callback URL configured in the OIDC provider',
quickSetCopy: 'Generate & Copy (current site)',
redirectUrlSetAndCopied: 'Redirect URL generated and copied to clipboard',
frontendRedirectUrl: 'Frontend Callback Path',
frontendRedirectUrlPlaceholder: '/auth/oidc/callback',
frontendRedirectUrlHint: 'Frontend route used after backend callback',
tokenAuthMethod: 'Token Auth Method',
clockSkewSeconds: 'Clock Skew (seconds)',
allowedSigningAlgs: 'Allowed Signing Algs',
allowedSigningAlgsPlaceholder: 'RS256,ES256,PS256',
usePkce: 'Use PKCE',
validateIdToken: 'Validate ID Token',
requireEmailVerified: 'Require Email Verified',
userinfoEmailPath: 'UserInfo Email Path',
userinfoEmailPathPlaceholder: 'for example data.email',
userinfoIdPath: 'UserInfo ID Path',
userinfoIdPathPlaceholder: 'for example data.id',
userinfoUsernamePath: 'UserInfo Username Path',
userinfoUsernamePathPlaceholder: 'for example data.username'
},
defaults: { defaults: {
title: 'Default User Settings', title: 'Default User Settings',
description: 'Default values for new users', description: 'Default values for new users',
......
...@@ -427,6 +427,7 @@ export default { ...@@ -427,6 +427,7 @@ export default {
invitationCodeInvalid: '邀请码无效或已被使用', invitationCodeInvalid: '邀请码无效或已被使用',
invitationCodeValidating: '正在验证邀请码...', invitationCodeValidating: '正在验证邀请码...',
invitationCodeInvalidCannotRegister: '邀请码无效,请检查后重试', invitationCodeInvalidCannotRegister: '邀请码无效,请检查后重试',
oauthOrContinue: '或使用邮箱密码继续',
linuxdo: { linuxdo: {
signIn: '使用 Linux.do 登录', signIn: '使用 Linux.do 登录',
orContinue: '或使用邮箱密码继续', orContinue: '或使用邮箱密码继续',
...@@ -441,6 +442,19 @@ export default { ...@@ -441,6 +442,19 @@ export default {
completing: '正在完成注册...', completing: '正在完成注册...',
completeRegistrationFailed: '注册失败,请检查邀请码后重试。' completeRegistrationFailed: '注册失败,请检查邀请码后重试。'
}, },
oidc: {
signIn: '使用 {providerName} 登录',
callbackTitle: '正在完成 {providerName} 登录',
callbackProcessing: '正在验证 {providerName} 登录信息,请稍候...',
callbackHint: '如果页面未自动跳转,请返回登录页重试。',
callbackMissingToken: '登录信息缺失,请返回重试。',
backToLogin: '返回登录',
invitationRequired: '该 {providerName} 账号尚未注册,站点已开启邀请码注册,请输入邀请码以完成注册。',
invalidPendingToken: '注册凭证已失效,请重新登录。',
completeRegistration: '完成注册',
completing: '正在完成注册...',
completeRegistrationFailed: '注册失败,请检查邀请码后重试。'
},
oauth: { oauth: {
code: '授权码', code: '授权码',
state: '状态', state: '状态',
...@@ -4394,6 +4408,57 @@ export default { ...@@ -4394,6 +4408,57 @@ export default {
quickSetCopy: '使用当前站点生成并复制', quickSetCopy: '使用当前站点生成并复制',
redirectUrlSetAndCopied: '已使用当前站点生成回调地址并复制到剪贴板' redirectUrlSetAndCopied: '已使用当前站点生成回调地址并复制到剪贴板'
}, },
oidc: {
title: 'OIDC 登录',
description: '配置标准 OIDC Provider(例如 Keycloak)',
enable: '启用 OIDC 登录',
enableHint: '在登录/注册页面显示 OIDC 登录入口',
providerName: 'Provider 名称',
providerNamePlaceholder: '例如 Keycloak',
clientId: 'Client ID',
clientIdPlaceholder: 'OIDC client id',
clientSecret: 'Client Secret',
clientSecretPlaceholder: '********',
clientSecretHint: '用于后端交换 token(请保密)',
clientSecretConfiguredPlaceholder: '********',
clientSecretConfiguredHint: '密钥已配置,留空以保留当前值。',
issuerUrl: 'Issuer URL',
issuerUrlPlaceholder: 'https://id.example.com/realms/main',
discoveryUrl: 'Discovery URL',
discoveryUrlPlaceholder: '可选,留空将基于 issuer 自动推导',
authorizeUrl: 'Authorize URL',
authorizeUrlPlaceholder: '可选,可通过 discovery 自动获取',
tokenUrl: 'Token URL',
tokenUrlPlaceholder: '可选,可通过 discovery 自动获取',
userinfoUrl: 'UserInfo URL',
userinfoUrlPlaceholder: '可选,可通过 discovery 自动获取',
jwksUrl: 'JWKS URL',
jwksUrlPlaceholder: '可选;启用严格 ID Token 校验时必填',
scopes: 'Scopes',
scopesPlaceholder: 'openid email profile',
scopesHint: '必须包含 openid',
redirectUrl: '后端回调地址(Redirect URL)',
redirectUrlPlaceholder: 'https://your-domain.com/api/v1/auth/oauth/oidc/callback',
redirectUrlHint: '必须与 OIDC Provider 中配置的回调地址一致',
quickSetCopy: '使用当前站点生成并复制',
redirectUrlSetAndCopied: '已使用当前站点生成回调地址并复制到剪贴板',
frontendRedirectUrl: '前端回调路径',
frontendRedirectUrlPlaceholder: '/auth/oidc/callback',
frontendRedirectUrlHint: '后端回调完成后重定向到此前端路径',
tokenAuthMethod: 'Token 鉴权方式',
clockSkewSeconds: '时钟偏移(秒)',
allowedSigningAlgs: '允许的签名算法',
allowedSigningAlgsPlaceholder: 'RS256,ES256,PS256',
usePkce: '启用 PKCE',
validateIdToken: '校验 ID Token',
requireEmailVerified: '要求邮箱已验证',
userinfoEmailPath: 'UserInfo 邮箱字段路径',
userinfoEmailPathPlaceholder: '例如 data.email',
userinfoIdPath: 'UserInfo ID 字段路径',
userinfoIdPathPlaceholder: '例如 data.id',
userinfoUsernamePath: 'UserInfo 用户名字段路径',
userinfoUsernamePathPlaceholder: '例如 data.username'
},
defaults: { defaults: {
title: '用户默认设置', title: '用户默认设置',
description: '新用户的默认值', description: '新用户的默认值',
......
...@@ -83,6 +83,15 @@ const routes: RouteRecordRaw[] = [ ...@@ -83,6 +83,15 @@ const routes: RouteRecordRaw[] = [
title: 'LinuxDo OAuth Callback' title: 'LinuxDo OAuth Callback'
} }
}, },
{
path: '/auth/oidc/callback',
name: 'OIDCOAuthCallback',
component: () => import('@/views/auth/OidcCallbackView.vue'),
meta: {
requiresAuth: false,
title: 'OIDC OAuth Callback'
}
},
{ {
path: '/forgot-password', path: '/forgot-password',
name: 'ForgotPassword', name: 'ForgotPassword',
......
...@@ -339,6 +339,9 @@ export const useAppStore = defineStore('app', () => { ...@@ -339,6 +339,9 @@ export const useAppStore = defineStore('app', () => {
custom_menu_items: [], custom_menu_items: [],
custom_endpoints: [], custom_endpoints: [],
linuxdo_oauth_enabled: false, linuxdo_oauth_enabled: false,
oidc_oauth_enabled: false,
oidc_oauth_provider_name: 'OIDC',
sora_client_enabled: false,
backend_mode_enabled: false, backend_mode_enabled: false,
version: siteVersion.value version: siteVersion.value
} }
......
...@@ -111,6 +111,9 @@ export interface PublicSettings { ...@@ -111,6 +111,9 @@ export interface PublicSettings {
custom_menu_items: CustomMenuItem[] custom_menu_items: CustomMenuItem[]
custom_endpoints: CustomEndpoint[] custom_endpoints: CustomEndpoint[]
linuxdo_oauth_enabled: boolean linuxdo_oauth_enabled: boolean
oidc_oauth_enabled: boolean
oidc_oauth_provider_name: string
sora_client_enabled: boolean
backend_mode_enabled: boolean backend_mode_enabled: boolean
version: string version: string
} }
...@@ -368,6 +371,13 @@ export type GroupPlatform = 'anthropic' | 'openai' | 'gemini' | 'antigravity' ...@@ -368,6 +371,13 @@ export type GroupPlatform = 'anthropic' | 'openai' | 'gemini' | 'antigravity'
export type SubscriptionType = 'standard' | 'subscription' export type SubscriptionType = 'standard' | 'subscription'
export interface OpenAIMessagesDispatchModelConfig {
opus_mapped_model?: string
sonnet_mapped_model?: string
haiku_mapped_model?: string
exact_model_mappings?: Record<string, string>
}
export interface Group { export interface Group {
id: number id: number
name: string name: string
...@@ -390,6 +400,8 @@ export interface Group { ...@@ -390,6 +400,8 @@ export interface Group {
fallback_group_id_on_invalid_request: number | null fallback_group_id_on_invalid_request: number | null
// OpenAI Messages 调度开关(用户侧需要此字段判断是否展示 Claude Code 教程) // OpenAI Messages 调度开关(用户侧需要此字段判断是否展示 Claude Code 教程)
allow_messages_dispatch?: boolean allow_messages_dispatch?: boolean
default_mapped_model?: string
messages_dispatch_model_config?: OpenAIMessagesDispatchModelConfig
require_oauth_only: boolean require_oauth_only: boolean
require_privacy_set: boolean require_privacy_set: boolean
created_at: string created_at: string
...@@ -416,6 +428,7 @@ export interface AdminGroup extends Group { ...@@ -416,6 +428,7 @@ export interface AdminGroup extends Group {
// OpenAI Messages 调度配置(仅 openai 平台使用) // OpenAI Messages 调度配置(仅 openai 平台使用)
default_mapped_model?: string default_mapped_model?: string
messages_dispatch_model_config?: OpenAIMessagesDispatchModelConfig
// 分组排序 // 分组排序
sort_order: number sort_order: number
......
This diff is collapsed.
...@@ -1124,7 +1124,327 @@ ...@@ -1124,7 +1124,327 @@
</div> </div>
</div> </div>
</div> </div>
</div><!-- /Tab: Security Registration, Turnstile, LinuxDo -->
<!-- Generic OIDC OAuth 登录 -->
<div class="card">
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
{{ t('admin.settings.oidc.title') }}
</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.settings.oidc.description') }}
</p>
</div>
<div class="space-y-5 p-6">
<div class="flex items-center justify-between">
<div>
<label class="font-medium text-gray-900 dark:text-white">{{
t('admin.settings.oidc.enable')
}}</label>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.settings.oidc.enableHint') }}
</p>
</div>
<Toggle v-model="form.oidc_connect_enabled" />
</div>
<div
v-if="form.oidc_connect_enabled"
class="space-y-6 border-t border-gray-100 pt-4 dark:border-dark-700"
>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.settings.oidc.providerName') }}
</label>
<input
v-model="form.oidc_connect_provider_name"
type="text"
class="input"
:placeholder="t('admin.settings.oidc.providerNamePlaceholder')"
/>
</div>
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.settings.oidc.clientId') }}
</label>
<input
v-model="form.oidc_connect_client_id"
type="text"
class="input font-mono text-sm"
:placeholder="t('admin.settings.oidc.clientIdPlaceholder')"
/>
</div>
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.settings.oidc.clientSecret') }}
</label>
<input
v-model="form.oidc_connect_client_secret"
type="password"
class="input font-mono text-sm"
:placeholder="
form.oidc_connect_client_secret_configured
? t('admin.settings.oidc.clientSecretConfiguredPlaceholder')
: t('admin.settings.oidc.clientSecretPlaceholder')
"
/>
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
{{
form.oidc_connect_client_secret_configured
? t('admin.settings.oidc.clientSecretConfiguredHint')
: t('admin.settings.oidc.clientSecretHint')
}}
</p>
</div>
</div>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.settings.oidc.issuerUrl') }}
</label>
<input
v-model="form.oidc_connect_issuer_url"
type="url"
class="input font-mono text-sm"
:placeholder="t('admin.settings.oidc.issuerUrlPlaceholder')"
/>
</div>
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.settings.oidc.discoveryUrl') }}
</label>
<input
v-model="form.oidc_connect_discovery_url"
type="url"
class="input font-mono text-sm"
:placeholder="t('admin.settings.oidc.discoveryUrlPlaceholder')"
/>
</div>
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.settings.oidc.authorizeUrl') }}
</label>
<input
v-model="form.oidc_connect_authorize_url"
type="url"
class="input font-mono text-sm"
:placeholder="t('admin.settings.oidc.authorizeUrlPlaceholder')"
/>
</div>
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.settings.oidc.tokenUrl') }}
</label>
<input
v-model="form.oidc_connect_token_url"
type="url"
class="input font-mono text-sm"
:placeholder="t('admin.settings.oidc.tokenUrlPlaceholder')"
/>
</div>
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.settings.oidc.userinfoUrl') }}
</label>
<input
v-model="form.oidc_connect_userinfo_url"
type="url"
class="input font-mono text-sm"
:placeholder="t('admin.settings.oidc.userinfoUrlPlaceholder')"
/>
</div>
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.settings.oidc.jwksUrl') }}
</label>
<input
v-model="form.oidc_connect_jwks_url"
type="url"
class="input font-mono text-sm"
:placeholder="t('admin.settings.oidc.jwksUrlPlaceholder')"
/>
</div>
</div>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.settings.oidc.scopes') }}
</label>
<input
v-model="form.oidc_connect_scopes"
type="text"
class="input font-mono text-sm"
:placeholder="t('admin.settings.oidc.scopesPlaceholder')"
/>
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.settings.oidc.scopesHint') }}
</p>
</div>
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.settings.oidc.redirectUrl') }}
</label>
<input
v-model="form.oidc_connect_redirect_url"
type="url"
class="input font-mono text-sm"
:placeholder="t('admin.settings.oidc.redirectUrlPlaceholder')"
/>
<div class="mt-2 flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
<button
type="button"
class="btn btn-secondary btn-sm w-fit"
@click="setAndCopyOIDCRedirectUrl"
>
{{ t('admin.settings.oidc.quickSetCopy') }}
</button>
<code
v-if="oidcRedirectUrlSuggestion"
class="select-all break-all rounded bg-gray-50 px-2 py-1 font-mono text-xs text-gray-600 dark:bg-dark-800 dark:text-gray-300"
>
{{ oidcRedirectUrlSuggestion }}
</code>
</div>
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.settings.oidc.redirectUrlHint') }}
</p>
</div>
<div class="lg:col-span-2">
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.settings.oidc.frontendRedirectUrl') }}
</label>
<input
v-model="form.oidc_connect_frontend_redirect_url"
type="text"
class="input font-mono text-sm"
:placeholder="t('admin.settings.oidc.frontendRedirectUrlPlaceholder')"
/>
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.settings.oidc.frontendRedirectUrlHint') }}
</p>
</div>
</div>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.settings.oidc.tokenAuthMethod') }}
</label>
<select v-model="form.oidc_connect_token_auth_method" class="input font-mono text-sm">
<option value="client_secret_post">client_secret_post</option>
<option value="client_secret_basic">client_secret_basic</option>
<option value="none">none</option>
</select>
</div>
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.settings.oidc.clockSkewSeconds') }}
</label>
<input
v-model.number="form.oidc_connect_clock_skew_seconds"
type="number"
min="0"
max="600"
class="input"
/>
</div>
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.settings.oidc.allowedSigningAlgs') }}
</label>
<input
v-model="form.oidc_connect_allowed_signing_algs"
type="text"
class="input font-mono text-sm"
:placeholder="t('admin.settings.oidc.allowedSigningAlgsPlaceholder')"
/>
</div>
</div>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
<div class="flex items-center justify-between rounded border border-gray-200 px-4 py-3 dark:border-dark-700">
<div>
<label class="font-medium text-gray-900 dark:text-white">
{{ t('admin.settings.oidc.usePkce') }}
</label>
</div>
<Toggle v-model="form.oidc_connect_use_pkce" />
</div>
<div class="flex items-center justify-between rounded border border-gray-200 px-4 py-3 dark:border-dark-700">
<div>
<label class="font-medium text-gray-900 dark:text-white">
{{ t('admin.settings.oidc.validateIdToken') }}
</label>
</div>
<Toggle v-model="form.oidc_connect_validate_id_token" />
</div>
<div class="flex items-center justify-between rounded border border-gray-200 px-4 py-3 dark:border-dark-700">
<div>
<label class="font-medium text-gray-900 dark:text-white">
{{ t('admin.settings.oidc.requireEmailVerified') }}
</label>
</div>
<Toggle v-model="form.oidc_connect_require_email_verified" />
</div>
</div>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.settings.oidc.userinfoEmailPath') }}
</label>
<input
v-model="form.oidc_connect_userinfo_email_path"
type="text"
class="input font-mono text-sm"
:placeholder="t('admin.settings.oidc.userinfoEmailPathPlaceholder')"
/>
</div>
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.settings.oidc.userinfoIdPath') }}
</label>
<input
v-model="form.oidc_connect_userinfo_id_path"
type="text"
class="input font-mono text-sm"
:placeholder="t('admin.settings.oidc.userinfoIdPathPlaceholder')"
/>
</div>
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.settings.oidc.userinfoUsernamePath') }}
</label>
<input
v-model="form.oidc_connect_userinfo_username_path"
type="text"
class="input font-mono text-sm"
:placeholder="t('admin.settings.oidc.userinfoUsernamePathPlaceholder')"
/>
</div>
</div>
</div>
</div>
</div>
</div><!-- /Tab: Security Registration, Turnstile, LinuxDo, OIDC -->
<!-- Tab: Users --> <!-- Tab: Users -->
<div v-show="activeTab === 'users'" class="space-y-6"> <div v-show="activeTab === 'users'" class="space-y-6">
...@@ -2240,6 +2560,7 @@ type SettingsForm = SystemSettings & { ...@@ -2240,6 +2560,7 @@ type SettingsForm = SystemSettings & {
smtp_password: string smtp_password: string
turnstile_secret_key: string turnstile_secret_key: string
linuxdo_connect_client_secret: string linuxdo_connect_client_secret: string
oidc_connect_client_secret: string
} }
const form = reactive<SettingsForm>({ const form = reactive<SettingsForm>({
...@@ -2289,6 +2610,30 @@ const form = reactive<SettingsForm>({ ...@@ -2289,6 +2610,30 @@ const form = reactive<SettingsForm>({
linuxdo_connect_client_secret: '', linuxdo_connect_client_secret: '',
linuxdo_connect_client_secret_configured: false, linuxdo_connect_client_secret_configured: false,
linuxdo_connect_redirect_url: '', linuxdo_connect_redirect_url: '',
// Generic OIDC OAuth 登录
oidc_connect_enabled: false,
oidc_connect_provider_name: 'OIDC',
oidc_connect_client_id: '',
oidc_connect_client_secret: '',
oidc_connect_client_secret_configured: false,
oidc_connect_issuer_url: '',
oidc_connect_discovery_url: '',
oidc_connect_authorize_url: '',
oidc_connect_token_url: '',
oidc_connect_userinfo_url: '',
oidc_connect_jwks_url: '',
oidc_connect_scopes: 'openid email profile',
oidc_connect_redirect_url: '',
oidc_connect_frontend_redirect_url: '/auth/oidc/callback',
oidc_connect_token_auth_method: 'client_secret_post',
oidc_connect_use_pkce: false,
oidc_connect_validate_id_token: true,
oidc_connect_allowed_signing_algs: 'RS256,ES256,PS256',
oidc_connect_clock_skew_seconds: 120,
oidc_connect_require_email_verified: false,
oidc_connect_userinfo_email_path: '',
oidc_connect_userinfo_id_path: '',
oidc_connect_userinfo_username_path: '',
// Model fallback // Model fallback
enable_model_fallback: false, enable_model_fallback: false,
fallback_model_anthropic: 'claude-3-5-sonnet-20241022', fallback_model_anthropic: 'claude-3-5-sonnet-20241022',
...@@ -2409,6 +2754,21 @@ async function setAndCopyLinuxdoRedirectUrl() { ...@@ -2409,6 +2754,21 @@ async function setAndCopyLinuxdoRedirectUrl() {
await copyToClipboard(url, t('admin.settings.linuxdo.redirectUrlSetAndCopied')) await copyToClipboard(url, t('admin.settings.linuxdo.redirectUrlSetAndCopied'))
} }
const oidcRedirectUrlSuggestion = computed(() => {
if (typeof window === 'undefined') return ''
const origin =
window.location.origin || `${window.location.protocol}//${window.location.host}`
return `${origin}/api/v1/auth/oauth/oidc/callback`
})
async function setAndCopyOIDCRedirectUrl() {
const url = oidcRedirectUrlSuggestion.value
if (!url) return
form.oidc_connect_redirect_url = url
await copyToClipboard(url, t('admin.settings.oidc.redirectUrlSetAndCopied'))
}
// Custom menu item management // Custom menu item management
function addMenuItem() { function addMenuItem() {
form.custom_menu_items.push({ form.custom_menu_items.push({
...@@ -2506,6 +2866,7 @@ async function loadSettings() { ...@@ -2506,6 +2866,7 @@ async function loadSettings() {
smtpPasswordManuallyEdited.value = false smtpPasswordManuallyEdited.value = false
form.turnstile_secret_key = '' form.turnstile_secret_key = ''
form.linuxdo_connect_client_secret = '' form.linuxdo_connect_client_secret = ''
form.oidc_connect_client_secret = ''
} catch (error: any) { } catch (error: any) {
loadFailed.value = true loadFailed.value = true
appStore.showError( appStore.showError(
...@@ -2673,6 +3034,28 @@ async function saveSettings() { ...@@ -2673,6 +3034,28 @@ async function saveSettings() {
linuxdo_connect_client_id: form.linuxdo_connect_client_id, linuxdo_connect_client_id: form.linuxdo_connect_client_id,
linuxdo_connect_client_secret: form.linuxdo_connect_client_secret || undefined, linuxdo_connect_client_secret: form.linuxdo_connect_client_secret || undefined,
linuxdo_connect_redirect_url: form.linuxdo_connect_redirect_url, linuxdo_connect_redirect_url: form.linuxdo_connect_redirect_url,
oidc_connect_enabled: form.oidc_connect_enabled,
oidc_connect_provider_name: form.oidc_connect_provider_name,
oidc_connect_client_id: form.oidc_connect_client_id,
oidc_connect_client_secret: form.oidc_connect_client_secret || undefined,
oidc_connect_issuer_url: form.oidc_connect_issuer_url,
oidc_connect_discovery_url: form.oidc_connect_discovery_url,
oidc_connect_authorize_url: form.oidc_connect_authorize_url,
oidc_connect_token_url: form.oidc_connect_token_url,
oidc_connect_userinfo_url: form.oidc_connect_userinfo_url,
oidc_connect_jwks_url: form.oidc_connect_jwks_url,
oidc_connect_scopes: form.oidc_connect_scopes,
oidc_connect_redirect_url: form.oidc_connect_redirect_url,
oidc_connect_frontend_redirect_url: form.oidc_connect_frontend_redirect_url,
oidc_connect_token_auth_method: form.oidc_connect_token_auth_method,
oidc_connect_use_pkce: form.oidc_connect_use_pkce,
oidc_connect_validate_id_token: form.oidc_connect_validate_id_token,
oidc_connect_allowed_signing_algs: form.oidc_connect_allowed_signing_algs,
oidc_connect_clock_skew_seconds: form.oidc_connect_clock_skew_seconds,
oidc_connect_require_email_verified: form.oidc_connect_require_email_verified,
oidc_connect_userinfo_email_path: form.oidc_connect_userinfo_email_path,
oidc_connect_userinfo_id_path: form.oidc_connect_userinfo_id_path,
oidc_connect_userinfo_username_path: form.oidc_connect_userinfo_username_path,
enable_model_fallback: form.enable_model_fallback, enable_model_fallback: form.enable_model_fallback,
fallback_model_anthropic: form.fallback_model_anthropic, fallback_model_anthropic: form.fallback_model_anthropic,
fallback_model_openai: form.fallback_model_openai, fallback_model_openai: form.fallback_model_openai,
...@@ -2700,6 +3083,7 @@ async function saveSettings() { ...@@ -2700,6 +3083,7 @@ async function saveSettings() {
smtpPasswordManuallyEdited.value = false smtpPasswordManuallyEdited.value = false
form.turnstile_secret_key = '' form.turnstile_secret_key = ''
form.linuxdo_connect_client_secret = '' form.linuxdo_connect_client_secret = ''
form.oidc_connect_client_secret = ''
// Refresh cached settings so sidebar/header update immediately // Refresh cached settings so sidebar/header update immediately
await appStore.fetchPublicSettings(true) await appStore.fetchPublicSettings(true)
await adminSettingsStore.fetch(true) await adminSettingsStore.fetch(true)
......
import { describe, expect, it } from "vitest";
import {
createDefaultMessagesDispatchFormState,
messagesDispatchConfigToFormState,
messagesDispatchFormStateToConfig,
resetMessagesDispatchFormState,
} from "../groupsMessagesDispatch";
describe("groupsMessagesDispatch", () => {
it("returns the expected default form state", () => {
expect(createDefaultMessagesDispatchFormState()).toEqual({
allow_messages_dispatch: false,
opus_mapped_model: "gpt-5.4",
sonnet_mapped_model: "gpt-5.3-codex",
haiku_mapped_model: "gpt-5.4-mini",
exact_model_mappings: [],
});
});
it("sanitizes exact model mapping rows when converting to config", () => {
const config = messagesDispatchFormStateToConfig({
allow_messages_dispatch: true,
opus_mapped_model: " gpt-5.4 ",
sonnet_mapped_model: "gpt-5.3-codex",
haiku_mapped_model: " gpt-5.4-mini ",
exact_model_mappings: [
{
claude_model: " claude-sonnet-4-5-20250929 ",
target_model: " gpt-5.2 ",
},
{ claude_model: "", target_model: "gpt-5.4" },
{ claude_model: "claude-opus-4-6", target_model: " " },
],
});
expect(config).toEqual({
opus_mapped_model: "gpt-5.4",
sonnet_mapped_model: "gpt-5.3-codex",
haiku_mapped_model: "gpt-5.4-mini",
exact_model_mappings: {
"claude-sonnet-4-5-20250929": "gpt-5.2",
},
});
});
it("hydrates form state from api config", () => {
expect(
messagesDispatchConfigToFormState({
opus_mapped_model: "gpt-5.4",
sonnet_mapped_model: "gpt-5.2",
haiku_mapped_model: "gpt-5.4-mini",
exact_model_mappings: {
"claude-opus-4-6": "gpt-5.4",
"claude-haiku-4-5-20251001": "gpt-5.4-mini",
},
}),
).toEqual({
allow_messages_dispatch: false,
opus_mapped_model: "gpt-5.4",
sonnet_mapped_model: "gpt-5.2",
haiku_mapped_model: "gpt-5.4-mini",
exact_model_mappings: [
{
claude_model: "claude-haiku-4-5-20251001",
target_model: "gpt-5.4-mini",
},
{ claude_model: "claude-opus-4-6", target_model: "gpt-5.4" },
],
});
});
it("resets mutable form state when platform switches away from openai", () => {
const state = {
allow_messages_dispatch: true,
opus_mapped_model: "gpt-5.2",
sonnet_mapped_model: "gpt-5.4",
haiku_mapped_model: "gpt-5.1",
exact_model_mappings: [
{ claude_model: "claude-opus-4-6", target_model: "gpt-5.4" },
],
};
resetMessagesDispatchFormState(state);
expect(state).toEqual({
allow_messages_dispatch: false,
opus_mapped_model: "gpt-5.4",
sonnet_mapped_model: "gpt-5.3-codex",
haiku_mapped_model: "gpt-5.4-mini",
exact_model_mappings: [],
});
});
});
import type { OpenAIMessagesDispatchModelConfig } from "@/types";
export interface MessagesDispatchMappingRow {
claude_model: string;
target_model: string;
}
export interface MessagesDispatchFormState {
allow_messages_dispatch: boolean;
opus_mapped_model: string;
sonnet_mapped_model: string;
haiku_mapped_model: string;
exact_model_mappings: MessagesDispatchMappingRow[];
}
export function createDefaultMessagesDispatchFormState(): MessagesDispatchFormState {
return {
allow_messages_dispatch: false,
opus_mapped_model: "gpt-5.4",
sonnet_mapped_model: "gpt-5.3-codex",
haiku_mapped_model: "gpt-5.4-mini",
exact_model_mappings: [],
};
}
export function messagesDispatchConfigToFormState(
config?: OpenAIMessagesDispatchModelConfig | null,
): MessagesDispatchFormState {
const defaults = createDefaultMessagesDispatchFormState();
const exactMappings = Object.entries(config?.exact_model_mappings || {})
.sort(([left], [right]) => left.localeCompare(right))
.map(([claude_model, target_model]) => ({ claude_model, target_model }));
return {
allow_messages_dispatch: false,
opus_mapped_model:
config?.opus_mapped_model?.trim() || defaults.opus_mapped_model,
sonnet_mapped_model:
config?.sonnet_mapped_model?.trim() || defaults.sonnet_mapped_model,
haiku_mapped_model:
config?.haiku_mapped_model?.trim() || defaults.haiku_mapped_model,
exact_model_mappings: exactMappings,
};
}
export function messagesDispatchFormStateToConfig(
state: MessagesDispatchFormState,
): OpenAIMessagesDispatchModelConfig {
const exactModelMappings = Object.fromEntries(
state.exact_model_mappings
.map((row) => [row.claude_model.trim(), row.target_model.trim()] as const)
.filter(([claudeModel, targetModel]) => claudeModel && targetModel),
);
return {
opus_mapped_model: state.opus_mapped_model.trim(),
sonnet_mapped_model: state.sonnet_mapped_model.trim(),
haiku_mapped_model: state.haiku_mapped_model.trim(),
exact_model_mappings: exactModelMappings,
};
}
export function resetMessagesDispatchFormState(
target: MessagesDispatchFormState,
): void {
const defaults = createDefaultMessagesDispatchFormState();
target.allow_messages_dispatch = defaults.allow_messages_dispatch;
target.opus_mapped_model = defaults.opus_mapped_model;
target.sonnet_mapped_model = defaults.sonnet_mapped_model;
target.haiku_mapped_model = defaults.haiku_mapped_model;
target.exact_model_mappings = [];
}
...@@ -11,8 +11,26 @@ ...@@ -11,8 +11,26 @@
</p> </p>
</div> </div>
<!-- LinuxDo Connect OAuth 登录 --> <div v-if="!backendModeEnabled && (linuxdoOAuthEnabled || oidcOAuthEnabled)" class="space-y-4">
<LinuxDoOAuthSection v-if="linuxdoOAuthEnabled && !backendModeEnabled" :disabled="isLoading" /> <LinuxDoOAuthSection
v-if="linuxdoOAuthEnabled"
:disabled="isLoading"
:show-divider="false"
/>
<OidcOAuthSection
v-if="oidcOAuthEnabled"
:disabled="isLoading"
:provider-name="oidcOAuthProviderName"
:show-divider="false"
/>
<div class="flex items-center gap-3">
<div class="h-px flex-1 bg-gray-200 dark:bg-dark-700"></div>
<span class="text-xs text-gray-500 dark:text-dark-400">
{{ t('auth.oauthOrContinue') }}
</span>
<div class="h-px flex-1 bg-gray-200 dark:bg-dark-700"></div>
</div>
</div>
<!-- Login Form --> <!-- Login Form -->
<form @submit.prevent="handleLogin" class="space-y-5"> <form @submit.prevent="handleLogin" class="space-y-5">
...@@ -181,6 +199,7 @@ import { useRouter } from 'vue-router' ...@@ -181,6 +199,7 @@ import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { AuthLayout } from '@/components/layout' import { AuthLayout } from '@/components/layout'
import LinuxDoOAuthSection from '@/components/auth/LinuxDoOAuthSection.vue' import LinuxDoOAuthSection from '@/components/auth/LinuxDoOAuthSection.vue'
import OidcOAuthSection from '@/components/auth/OidcOAuthSection.vue'
import TotpLoginModal from '@/components/auth/TotpLoginModal.vue' import TotpLoginModal from '@/components/auth/TotpLoginModal.vue'
import Icon from '@/components/icons/Icon.vue' import Icon from '@/components/icons/Icon.vue'
import TurnstileWidget from '@/components/TurnstileWidget.vue' import TurnstileWidget from '@/components/TurnstileWidget.vue'
...@@ -207,6 +226,8 @@ const turnstileEnabled = ref<boolean>(false) ...@@ -207,6 +226,8 @@ const turnstileEnabled = ref<boolean>(false)
const turnstileSiteKey = ref<string>('') const turnstileSiteKey = ref<string>('')
const linuxdoOAuthEnabled = ref<boolean>(false) const linuxdoOAuthEnabled = ref<boolean>(false)
const backendModeEnabled = ref<boolean>(false) const backendModeEnabled = ref<boolean>(false)
const oidcOAuthEnabled = ref<boolean>(false)
const oidcOAuthProviderName = ref<string>('OIDC')
const passwordResetEnabled = ref<boolean>(false) const passwordResetEnabled = ref<boolean>(false)
// Turnstile // Turnstile
...@@ -247,6 +268,9 @@ onMounted(async () => { ...@@ -247,6 +268,9 @@ onMounted(async () => {
turnstileSiteKey.value = settings.turnstile_site_key || '' turnstileSiteKey.value = settings.turnstile_site_key || ''
linuxdoOAuthEnabled.value = settings.linuxdo_oauth_enabled linuxdoOAuthEnabled.value = settings.linuxdo_oauth_enabled
backendModeEnabled.value = settings.backend_mode_enabled backendModeEnabled.value = settings.backend_mode_enabled
oidcOAuthEnabled.value = settings.oidc_oauth_enabled
oidcOAuthProviderName.value = settings.oidc_oauth_provider_name || 'OIDC'
backendModeEnabled.value = settings.backend_mode_enabled
passwordResetEnabled.value = settings.password_reset_enabled passwordResetEnabled.value = settings.password_reset_enabled
} catch (error) { } catch (error) {
console.error('Failed to load public settings:', error) console.error('Failed to load public settings:', error)
......
This diff is collapsed.
...@@ -11,8 +11,26 @@ ...@@ -11,8 +11,26 @@
</p> </p>
</div> </div>
<!-- LinuxDo Connect OAuth 登录 --> <div v-if="linuxdoOAuthEnabled || oidcOAuthEnabled" class="space-y-4">
<LinuxDoOAuthSection v-if="linuxdoOAuthEnabled" :disabled="isLoading" /> <LinuxDoOAuthSection
v-if="linuxdoOAuthEnabled"
:disabled="isLoading"
:show-divider="false"
/>
<OidcOAuthSection
v-if="oidcOAuthEnabled"
:disabled="isLoading"
:provider-name="oidcOAuthProviderName"
:show-divider="false"
/>
<div class="flex items-center gap-3">
<div class="h-px flex-1 bg-gray-200 dark:bg-dark-700"></div>
<span class="text-xs text-gray-500 dark:text-dark-400">
{{ t('auth.oauthOrContinue') }}
</span>
<div class="h-px flex-1 bg-gray-200 dark:bg-dark-700"></div>
</div>
</div>
<!-- Registration Disabled Message --> <!-- Registration Disabled Message -->
<div <div
...@@ -289,6 +307,7 @@ import { useRouter, useRoute } from 'vue-router' ...@@ -289,6 +307,7 @@ import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { AuthLayout } from '@/components/layout' import { AuthLayout } from '@/components/layout'
import LinuxDoOAuthSection from '@/components/auth/LinuxDoOAuthSection.vue' import LinuxDoOAuthSection from '@/components/auth/LinuxDoOAuthSection.vue'
import OidcOAuthSection from '@/components/auth/OidcOAuthSection.vue'
import Icon from '@/components/icons/Icon.vue' import Icon from '@/components/icons/Icon.vue'
import TurnstileWidget from '@/components/TurnstileWidget.vue' import TurnstileWidget from '@/components/TurnstileWidget.vue'
import { useAuthStore, useAppStore } from '@/stores' import { useAuthStore, useAppStore } from '@/stores'
...@@ -324,6 +343,8 @@ const turnstileEnabled = ref<boolean>(false) ...@@ -324,6 +343,8 @@ const turnstileEnabled = ref<boolean>(false)
const turnstileSiteKey = ref<string>('') const turnstileSiteKey = ref<string>('')
const siteName = ref<string>('Sub2API') const siteName = ref<string>('Sub2API')
const linuxdoOAuthEnabled = ref<boolean>(false) const linuxdoOAuthEnabled = ref<boolean>(false)
const oidcOAuthEnabled = ref<boolean>(false)
const oidcOAuthProviderName = ref<string>('OIDC')
const registrationEmailSuffixWhitelist = ref<string[]>([]) const registrationEmailSuffixWhitelist = ref<string[]>([])
// Turnstile // Turnstile
...@@ -376,6 +397,8 @@ onMounted(async () => { ...@@ -376,6 +397,8 @@ onMounted(async () => {
turnstileSiteKey.value = settings.turnstile_site_key || '' turnstileSiteKey.value = settings.turnstile_site_key || ''
siteName.value = settings.site_name || 'Sub2API' siteName.value = settings.site_name || 'Sub2API'
linuxdoOAuthEnabled.value = settings.linuxdo_oauth_enabled linuxdoOAuthEnabled.value = settings.linuxdo_oauth_enabled
oidcOAuthEnabled.value = settings.oidc_oauth_enabled
oidcOAuthProviderName.value = settings.oidc_oauth_provider_name || 'OIDC'
registrationEmailSuffixWhitelist.value = normalizeRegistrationEmailSuffixWhitelist( registrationEmailSuffixWhitelist.value = normalizeRegistrationEmailSuffixWhitelist(
settings.registration_email_suffix_whitelist || [] settings.registration_email_suffix_whitelist || []
) )
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment