fix(openai): honor account allowlists for unlisted models

Let OpenAI accounts send explicitly allowlisted model IDs upstream while retaining catalog checks for other accounts. Update the account editor and connection test to support custom IDs.

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-11-06 12:18:08 +08:00
co-authored by Codex
parent 04a48e848d
commit 09b22a9550
10 changed files with 239 additions and 193 deletions
@@ -9,10 +9,10 @@ use gateway_core::error::ErrorSource;
use gateway_core::account::{
AccountAffinity, AccountCandidate, AccountCapacitySnapshot, AccountErrorReason,
AccountFeedbackStats, AccountQuotaSignals, AccountRuntimeSignals, AccountSchedulingBlocker,
AccountSelectionContext, AccountSelectionPolicy, AccountSelector, AccountStatus,
CredentialState, PreferredAccountSelection, ProviderAccount, ProviderAccountId, QuotaEvidence,
RotationStrategy,
AccountFeedbackStats, AccountModelAccessMode, AccountQuotaSignals, AccountRuntimeSignals,
AccountSchedulingBlocker, AccountSelectionContext, AccountSelectionPolicy, AccountSelector,
AccountStatus, CredentialState, PreferredAccountSelection, ProviderAccount, ProviderAccountId,
QuotaEvidence, RotationStrategy,
};
use gateway_core::concurrency::{CapacityWait, ConcurrencyWaitQueue, QueueRejection, WaitPriority};
use gateway_core::engine::{AttemptContext, ContinuationAttempt, policy::AccountPolicyError};
@@ -635,10 +635,19 @@ impl CodexCredentialSelector {
})
.collect::<Vec<_>>();
let mut eligible = Vec::with_capacity(accounts.len());
let catalog_excluded = match model {
CodexSelectionModel::Requested(model) if !diagnostic => self
.catalog
.scheduling_exclusions(&accounts, model, SystemTime::now()),
let catalog_excluded = match (model, request.attempt.account_scope()) {
(CodexSelectionModel::Requested(model), Some(scope)) if !diagnostic => {
// 候选已通过冻结的模型政策,白名单精确命中的 ID 交给上游验证
let mut excluded =
self.catalog
.scheduling_exclusions(&accounts, model, SystemTime::now());
excluded.retain(|id| {
scope.directory().account(id).is_none_or(|account| {
account.model_access().mode() != AccountModelAccessMode::Allowlist
})
});
excluded
}
_ => Default::default(),
};
let mut catalog_rejected = 0_usize;
@@ -141,8 +141,11 @@ async fn unlisted_models_remain_available_without_catalog_discovery() {
}
#[tokio::test]
async fn catalog_membership_and_model_access_precede_weight_affinity_and_fixed_accounts() {
use gateway_core::account::{AccountModelAccess, AccountModelAccessMode};
async fn account_allowlists_override_catalog_omissions_but_weight_affinity_and_pinning_do_not() {
use gateway_core::account::{
AccountModelAccess,
AccountModelAccessMode::{All, Allowlist, Denylist},
};
use provider_openai::credential::ResponsesTransport;
for (primary_api, secondary_api) in [(false, false), (true, true), (true, false), (false, true)]
@@ -264,52 +267,45 @@ async fn catalog_membership_and_model_access_precede_weight_affinity_and_fixed_a
}
}
assert_eq!(affinity.binding_count(), 1);
// 目录资格不能被固定账号、模型允许列表或另一个账号的目录放宽
for (model, required, disable_supported) in [
("gpt-6-astra", Some("acct_scope_new"), false),
("custom-unlisted", None, false),
("gpt-6-astra", None, true),
let primary = "acct_provider_contract";
let secondary = "acct_scope_new";
// 目录外许可只属于明确添加该模型的账号,不能借用同池账号的白名单
for (model, required, primary_mode, secondary_mode, expected) in [
("gpt-6-astra", Some(secondary), All, All, None),
("custom-unlisted", None, All, All, None),
("gpt-6.1-sol", None, Allowlist, All, Some(primary)),
("gpt-6-astra", None, Denylist, Allowlist, Some(secondary)),
(
"custom-unlisted",
None,
Denylist,
Allowlist,
Some(secondary),
),
("gpt-6-luna", Some(primary), Denylist, Allowlist, None),
] {
let scope = if disable_supported {
Arc::new(FrozenAccountScope::new(
Arc::new(RuntimeAccountDirectory::new(BTreeMap::from([
(
ProviderAccountId::new("acct_provider_contract").unwrap(),
RuntimeAccount::new(
ProviderKind::new("openai").unwrap(),
BTreeSet::new(),
)
.with_model_access(
AccountModelAccess::new(
AccountModelAccessMode::Denylist,
vec![model.to_owned()],
)
.unwrap(),
),
),
(
ProviderAccountId::new("acct_scope_new").unwrap(),
RuntimeAccount::new(
ProviderKind::new("openai").unwrap(),
BTreeSet::new(),
)
.with_model_access(
AccountModelAccess::new(
AccountModelAccessMode::Allowlist,
vec![model.to_owned()],
)
.unwrap(),
),
),
]))),
ClientRoutingScope::all_accounts(),
))
} else {
contract_account_scope()
};
let accounts = [(primary, primary_mode), (secondary, secondary_mode)]
.into_iter()
.map(|(id, mode)| {
let access = if mode == All {
AccountModelAccess::all()
} else {
AccountModelAccess::new(mode, vec![model.to_owned()]).unwrap()
};
(
ProviderAccountId::new(id).unwrap(),
RuntimeAccount::new(ProviderKind::new("openai").unwrap(), BTreeSet::new())
.with_model_access(access),
)
})
.collect();
let scope = Arc::new(FrozenAccountScope::new(
Arc::new(RuntimeAccountDirectory::new(accounts)),
ClientRoutingScope::all_accounts(),
));
let attempt = AttemptContext::new(
RequestAttemptContext::new(
ModelRequestId::new("req_catalog_rejected").unwrap(),
ModelRequestId::new("req_account_catalog_policy").unwrap(),
ClientApiKeyId::new("key_openai_contract").unwrap(),
),
NonZeroU32::MIN,
@@ -325,19 +321,26 @@ async fn catalog_membership_and_model_access_precede_weight_affinity_and_fixed_a
CancellationToken::new(),
);
let acquisitions = leases.requests.lock().unwrap().len();
let error = provider
let result = provider
.clone()
.execute(
planned_request_for_model("openai", operation(model), model),
attempt,
)
.await
.err()
.expect("catalog and model access must both permit the account");
assert_eq!(error.kind(), ProviderErrorKind::NoEligibleAccount);
assert_eq!(leases.requests.lock().unwrap().len(), acquisitions);
.await;
if let Some(expected) = expected {
let mut stream = result.unwrap();
assert_eq!(stream.metadata().provider_account_id().as_str(), expected);
while let Some(event) = stream.next().await {
event.unwrap();
}
} else {
let error = result.err().expect("account must be excluded");
assert_eq!(error.kind(), ProviderErrorKind::NoEligibleAccount);
assert_eq!(leases.requests.lock().unwrap().len(), acquisitions);
}
}
let sent = native
let sent: Vec<_> = native
.received_requests()
.await
.unwrap()
@@ -345,8 +348,16 @@ async fn catalog_membership_and_model_access_precede_weight_affinity_and_fixed_a
.chain(api_primary.received_requests().await.unwrap())
.chain(api_secondary.received_requests().await.unwrap())
.filter(|request| request.method == "POST")
.count();
assert_eq!(sent, 2);
.map(|request| {
captured_request_body(&request)["model"]
.as_str()
.unwrap()
.to_owned()
})
.collect();
assert_eq!(sent.len(), 5);
assert!(sent.iter().any(|model| model == "gpt-6.1-sol"));
assert!(sent.iter().any(|model| model == "custom-unlisted"));
}
}
+7 -5
View File
@@ -579,7 +579,7 @@ xAI 没有 Codex 原生目录,使用官方 CLI proxy 当前目录生成通用
已知模型只路由到明确提供该模型的 Provider,账号不可用时不转发给其他平台的未知模型通道。
未知模型只在当前范围与路由约束下存在唯一 Provider 透传候选时发送;无法确定归属时返回不可用错误,
可通过 Client Key 分组限定目标平台。OpenAI OAuth、API Key 上游和 xAI 账号均叠加有效目录资格与账号模型政策,
可通过 Client Key 分组限定目标平台。Provider 在选号时检查账号模型政策与目录资格,
具体边界见[账号模型限制](#账号模型限制);ChatGPT OAuth 账号不接受 Grok 模型
目录账号只能来自本次 Client Key 冻结的账号范围。OpenAI 的 OAuth 目录按账号 ID 排序,使用首个成功读取的
@@ -1226,14 +1226,16 @@ Key 预算及其访问状态随互联默认公开,不要求开启 `shareQuota`
最多 256 项,每个 ID 最多 256 字节;重复 ID 去重,不接受空白、控制字符、`__` 前缀或 `*` 通配符。
允许保存当前上游目录尚未返回的 ID。限制按全局模型映射后的上游 ID 判断,不扩大账号本身的上游权限。
管理端刷新目录只更新目录,不修改模型限制。白名单的清理按钮列出当前目录未返回的已选 ID,默认不选中任何项,
管理端刷新目录只更新目录,不修改模型限制。全选与取消全选只作用于上游目录中符合当前搜索条件的模型。
白名单的清理按钮列出当前目录未返回的已选 ID,默认不选中任何项,
由管理员确认后从当前表单移除,保存账号后生效。目录未就绪、刷新失败或返回空目录时不能清理,清理也不能将白名单置空。
图像、搜索、插件适配模型和映射目标可能不在目录中,目录缺失本身不代表模型失效。
例如 Plus 设置 `allowlist` 并选中 luna 的实际 ID,Pro 设置 `denylist` 并选中同一 ID,即可严格分流;
Pro 使用 `all` 时也能参与 luna 调度。套餐名称不自动生成或修改规则
原生 Responses 选号同时检查有效模型目录与 `modelAccess`,OAuth、API Key 上游和 xAI 使用相同资格规则:
有效目录未包含请求模型时排除该账号,目录包含模型也不能绕过禁用或黑白名单;权重、亲和和固定账号均不能放宽资格。
原生 Responses 默认排除有效目录未列出目标模型的账号。OpenAI OAuth 或 API Key 账号的白名单精确包含映射后的上游 ID 时,
允许通过该账号请求目录外模型,由上游确认是否可用。此设置只对配置它的账号生效,已有白名单同样适用。
账号禁用、Key 分组、认证类型、黑名单和模型冷却仍参与选号。xAI 账号使用有效套餐目录与账号模型政策的交集。
OAuth 和 xAI 按已知套餐匹配目录,API Key 按账号与凭据版本隔离目录,不借用其他账号的模型集合。
OpenAI 使用最近 30 分钟内同步的目录,xAI 使用未过期的套餐目录缓存;未知套餐或无有效目录时交由上游验证,仍执行 `modelAccess`
@@ -1251,7 +1253,7 @@ OpenAI 使用最近 30 分钟内同步的目录,xAI 使用未过期的套餐
Images 和 Search 按原始请求正文的模型 ID 检查,不依赖模型目录或全局模型映射;可手动添加目录未列出的模型 ID。
模型缺失、重复或无法识别时,仅使用模型政策为 `all` 的账号,不推测上游默认模型。
Search 的选号限制不约束插件在选号后主动改写正文。
管理员连接测试不受账号模型政策、目录资格筛选与模型拒绝冷却限制;连接测试成功只证明指定账号的上游能力。
管理员连接测试的选项包含上游目录和账号白名单中的 ID。连接测试跳过普通选号的模型政策、目录资格筛选与模型拒绝冷却,成功表示指定账号可调用测试使用的端点与模型。
普通 `/v1/models` 和单模型查询将已发现的 OpenAI 模型关联到来源账号,至少一个来源账号在当前 Key 范围内且政策允许时才展示;
同 Provider 中未发现该模型的 `all` 账号不会使它进入列表。原生目录保留已有来源选择和完整模型对象
+3 -3
View File
@@ -517,7 +517,7 @@ credential state 调度,不以是否存在上游用户 ID 推断可用性;OA
API Key 默认 HTTP/SSE,可选 WS 优先;选号先验证传输资格,WS pool 与 continuation 按凭据版本隔离。
OAuth 与 API Key 共用业务请求、响应和能力透传链路,差异限定在上游地址、认证、传输配置及明确的上游请求合同适配。
OpenAI 模型目录用于发现与原生 Responses 账号资格筛选,OAuth 按套餐、API Key 按账号与凭据版本匹配目录。
未知目录或未知套餐保留上游验证;管理员配置的模型权限仍由 Core 与选号链路执行
目录与账号白名单的优先级见[路由与账号范围](#6-路由账号范围与-continuation)
- OpenAI 是透明边界。正文兼容、身份归一和显式请求策略之外的业务字段保留原始语义;
业务响应按原始字节转发,客户端错误兼容与原生续写额度恢复按明确例外处理,见 [OpenAI 透传](api.md#openai-透传)。
@@ -747,8 +747,8 @@ OpenAI Provider 在选号时排除 Grok 模型的 ChatGPT OAuth 账号,API Key
OpenAI Provider 的账号资格模块统一解释请求类型、认证与套餐限制,Images 和 Live 共用 OAuth 套餐检查。
资格检查复用账号持久事实,在获取租约前与冻结的模型黑白名单共同过滤候选;亲和与重试沿用同一检查
原生 Responses 在评分、亲和和租约前将目录资格与冻结的模型政策取交集:有效目录未包含请求模型时排除账号,
目录包含模型不能解除管理员限制。OpenAI OAuth 与 xAI 按已知套餐匹配,API Key 按账号与凭据版本匹配。
原生 Responses 在评分、亲和和租约前检查冻结的账号模型政策。OpenAI 账号白名单精确命中目标 ID 时允许上游验证,
其余 OpenAI 账号及 xAI 账号按有效目录排除未列出的模型。OpenAI OAuth 与 xAI 按已知套餐匹配,API Key 按账号与凭据版本匹配。
OpenAI 使用未满 30 分钟的进程目录快照,xAI 按套餐去重读取 TTL 目录缓存,选号不请求上游目录。
未知套餐、缺失或过期目录保留上游验证,不从其他账号或套餐借用目录资格
+9 -7
View File
@@ -186,19 +186,21 @@
"reserve": "Reserve",
"allowlist": "Allowlist",
"blocklist": "Blocklist",
"modelCatalog": "Model catalog",
"refreshModel": "Refresh model",
"modelRestrictionMode": "Model restriction mode",
"modelRestrictionMode": "Model restrictions",
"catalogHelpTitle": "Model catalog details",
"catalogObservedAtHelp": "The time shows when the upstream catalog was last successfully fetched",
"catalogDescription": "The catalog lists model IDs, verify endpoint support with an actual request — a connection test only checks the endpoint it uses",
"searchOrAddModels": "Search or add models",
"searchOrEnterIdAndPressEnter": "Search or enter ID and press Enter to add",
"selectModel": "Select model",
"notInCurrentModelCatalog": "{id} (not in the current model catalog)",
"modelCatalogMissingHint": "The current upstream catalog does not list this model",
"searchOrEnterId": "Search models or enter a full ID",
"addModel": "Add model",
"customModels": "Custom",
"selectedModels": "Selected models",
"upstreamCatalog": "Upstream catalog",
"selectAll": "Select all",
"deselectAll": "Deselect all",
"addToDenylistHint": "Saving blocks this ID on the account",
"loadingModel": "Loading model",
"noMatchingModels": "No matching models",
"noModels": "No models",
"selectOrAddAtLeastOneModel": "Please select or add at least one model",
"configureUpToModels": "Configure up to 256 models",
@@ -186,19 +186,21 @@
"reserve": "保留",
"allowlist": "白名单",
"blocklist": "黑名单",
"modelCatalog": "模型目录",
"refreshModel": "刷新模型",
"modelRestrictionMode": "模型限制模式",
"modelRestrictionMode": "模型限制",
"catalogHelpTitle": "模型目录说明",
"catalogObservedAtHelp": "时间表示最近一次成功获取上游目录的时间",
"catalogDescription": "目录仅提供模型 ID,端点能力请通过实际请求确认,连接测试仅验证测试所用端点",
"searchOrAddModels": "搜索或添加模型",
"searchOrEnterIdAndPressEnter": "搜索或输入 ID,回车添加",
"selectModel": "选择模型",
"notInCurrentModelCatalog": "{id}(当前目录未返回)",
"modelCatalogMissingHint": "当前上游目录未返回此模型",
"searchOrEnterId": "搜索模型或输入完整 ID",
"addModel": "添加模型",
"customModels": "自定义",
"selectedModels": "已选模型",
"upstreamCatalog": "上游目录",
"selectAll": "全选",
"deselectAll": "取消全选",
"addToDenylistHint": "保存后,此账号将拒绝该模型",
"loadingModel": "加载模型中",
"noMatchingModels": "无匹配模型",
"noModels": "暂无模型",
"selectOrAddAtLeastOneModel": "请至少选择或添加一个模型",
"configureUpToModels": "最多配置 256 个模型",
@@ -186,19 +186,21 @@
"reserve": "保留",
"allowlist": "白名單",
"blocklist": "黑名單",
"modelCatalog": "模型目錄",
"refreshModel": "刷新模型",
"modelRestrictionMode": "模型限制模式",
"modelRestrictionMode": "模型限制",
"catalogHelpTitle": "模型目錄說明",
"catalogObservedAtHelp": "時間表示最近一次成功取得上游目錄的時間",
"catalogDescription": "目錄僅提供模型 ID,端點能力請透過實際請求確認,連線測試僅驗證測試所用端點",
"searchOrAddModels": "搜索或添加模型",
"searchOrEnterIdAndPressEnter": "搜索或輸入 ID,回車添加",
"selectModel": "選擇模型",
"notInCurrentModelCatalog": "{id}(當前目錄未返回)",
"modelCatalogMissingHint": "當前上游目錄未返回此模型",
"searchOrEnterId": "搜索模型或輸入完整 ID",
"addModel": "添加模型",
"customModels": "自定義",
"selectedModels": "已選模型",
"upstreamCatalog": "上游目錄",
"selectAll": "全選",
"deselectAll": "取消全選",
"addToDenylistHint": "保存後,此賬號將拒絕該模型",
"loadingModel": "加載模型中",
"noMatchingModels": "無匹配模型",
"noModels": "暫無模型",
"selectOrAddAtLeastOneModel": "請至少選擇或添加一個模型",
"configureUpToModels": "最多配置 256 個模型",
@@ -82,7 +82,7 @@ async function requestSave() {
<ZDialog
v-model="open"
:title="t('accounts.editor.title')"
width="42rem"
width="52rem"
:show-close="!saving" :close-on-click-modal="!saving" :close-on-press-escape="!saving"
@closed="emit('afterLeave')"
>
@@ -127,7 +127,7 @@ async function requestSave() {
>
<template v-if="isOpenAiOAuthAccount(account)" #name-extra>
<ZFormItem :label="t('accounts.editor.transportLabel')">
<ZSegmented v-model="oauthTransport" class="w-full" :aria-label="t('accounts.editor.transportLabel')" :options="transportOptions" :disabled="saving || !configurationReady" />
<ZSegmented v-model="oauthTransport" class="w-40" :aria-label="t('accounts.editor.transportLabel')" :options="transportOptions" :disabled="saving || !configurationReady" />
</ZFormItem>
</template>
</AccountSettingsFields>
@@ -1,9 +1,9 @@
<script setup lang="ts">
import type { AccountModelAccess } from '@/api'
import { ZEmpty, ZFormItem, ZIconButton, ZInput, ZPopover, ZScrollbar, ZSegmented } from '@codex-proxy/ui'
import { ZButton, ZEmpty, ZFormItem, ZIconButton, ZInput, ZPopover, ZScrollbar, ZSegmented } from '@codex-proxy/ui'
import { CircleAlert, RefreshCw, Search, TriangleAlert } from '@lucide/vue'
import { CircleAlert, ListChecks, ListMinus, Plus, RefreshCw, Search } from '@lucide/vue'
import { computed, ref, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { getAccountModels, refreshAccountModels } from '@/api'
@@ -29,7 +29,8 @@ watch(model, () => formItem.value?.clearValidate())
const catalog = ref<Array<{ id: string, label: string }>>()
const catalogObservedAtDisplay = ref<string>()
const search = ref('')
const inputError = ref('')
const searchInput = useTemplateRef<InstanceType<typeof ZInput>>('searchInput')
const selectionError = ref('')
const request = useRequestState()
const { loading, error } = request
const modes = computed(() => [
@@ -57,7 +58,7 @@ const modes = computed(() => [
const mode = computed({
get: () => model.value?.mode ?? 'preserve',
set: (value: string) => {
inputError.value = ''
selectionError.value = ''
if (value === 'preserve') {
model.value = undefined
return
@@ -73,29 +74,40 @@ const mode = computed({
},
})
const restricted = computed(() => mode.value === 'allowlist' || mode.value === 'denylist')
const canAdd = computed(() => Boolean(search.value.trim()) && !model.value?.models.includes(search.value.trim()))
const selectedIds = computed(() => new Set(model.value?.models))
const catalogReady = computed(() => catalog.value !== undefined && !loading.value && !error.value)
const missingModels = computed(() => {
// 空目录和失败不能作为批量清理依据,目录缺失也不等于独立端点或适配模型无效
if (mode.value !== 'allowlist' || !catalogReady.value || !catalog.value?.length)
return []
const ids = new Set(catalog.value.map(item => item.id))
const extraIds = computed(() => {
const ids = new Set(catalog.value?.map(item => item.id))
return (model.value?.models ?? []).filter(id => !ids.has(id))
})
const models = computed(() => {
const entries = new Map((catalog.value ?? []).map(item => [
item.id,
{ ...item, unavailable: false },
]))
for (const id of model.value?.models ?? []) {
if (!entries.has(id))
entries.set(id, { id, label: id, unavailable: catalogReady.value })
}
const query = search.value.trim().toLowerCase()
return [
...entries.values(),
].filter(item => `${item.id} ${item.label}`.toLowerCase().includes(query))
})
// 目录已就绪且非空时才能按目录清理白名单
const missingModels = computed(() => mode.value === 'allowlist' && catalogReady.value && catalog.value?.length
? extraIds.value
: [])
const query = computed(() => search.value.trim().toLowerCase())
const catalogResults = computed(() => (catalog.value ?? [])
.filter(item => `${item.id} ${item.label}`.toLowerCase().includes(query.value))
.map(item => item.id))
const allResultsSelected = computed(() => catalogResults.value.every(id => selectedIds.value.has(id)))
const groups = computed(() => [
{
key: 'selected',
label: catalogReady.value
? t('accounts.modelAccess.customModels')
: t('accounts.modelAccess.selectedModels'),
ids: extraIds.value.filter(id => id.toLowerCase().includes(query.value)),
},
{
key: 'catalog',
label: t('accounts.modelAccess.upstreamCatalog'),
ids: catalogResults.value,
},
].filter(group => group.ids.length))
const newModelId = computed(() => groups.value.length
? ''
: search.value.trim())
const inputError = computed(() => selectionError.value || (newModelId.value && accountModelIdError(newModelId.value)))
const canAdd = computed(() => Boolean(newModelId.value) && !inputError.value)
function removeMissingModels(ids: string[]) {
if (props.disabled || model.value?.mode !== 'allowlist')
@@ -108,8 +120,8 @@ function removeMissingModels(ids: string[]) {
...model.value,
models: model.value.models.filter(id => !removed.has(id)),
}
inputError.value = accountModelAccessError(next) ?? ''
if (!inputError.value)
selectionError.value = accountModelAccessError(next) ?? ''
if (!selectionError.value)
model.value = next
}
@@ -119,34 +131,41 @@ function restoreCleanupFocus() {
refreshButton.value?.focus()
}
function select(id: string, selected: boolean) {
function select(modelIds: string[], selected: boolean) {
if (props.disabled || !model.value || !restricted.value)
return
const ids = new Set(model.value.models)
if (selected)
ids.add(id)
else ids.delete(id)
const ids = new Set(selectedIds.value)
for (const id of modelIds) {
if (selected)
ids.add(id)
else ids.delete(id)
}
const next = {
...model.value,
models: [...ids],
}
inputError.value = selected
selectionError.value = selected
? accountModelAccessError(next) ?? ''
: ''
if (!inputError.value)
if (!selectionError.value)
model.value = next
}
function addModel() {
if (props.disabled || !restricted.value || !canAdd.value)
return
const id = search.value.trim()
inputError.value = accountModelIdError(id) ?? ''
if (inputError.value)
return
select(id, true)
if (!inputError.value)
select([newModelId.value], true)
if (!selectionError.value) {
search.value = ''
searchInput.value?.focus()
}
}
function handleSearchEnter(event: KeyboardEvent) {
if (event.isComposing)
return
event.preventDefault()
addModel()
}
function handleCatalogHelpKeydown(event: KeyboardEvent, open: boolean, close: () => void) {
@@ -189,22 +208,22 @@ watch([
catalog.value = undefined
catalogObservedAtDisplay.value = undefined
search.value = ''
inputError.value = ''
selectionError.value = ''
error.value = ''
void load()
}, { immediate: true })
watch(search, () => {
inputError.value = ''
selectionError.value = ''
})
</script>
<template>
<div class="grid gap-4.5">
<div class="@container grid gap-4.5">
<ZFormItem ref="modelAccessItem" :prop="formProp">
<template #label>
<span class="inline-flex flex-wrap items-end gap-x-2 gap-y-1">
<span>{{ t('accounts.modelAccess.modelCatalog') }}</span>
<span>{{ t('accounts.modelAccess.modelRestrictionMode') }}</span>
<span v-if="restricted && catalogObservedAtDisplay" class="text-z-xs leading-none font-normal text-z-text-tertiary tabular-nums">
{{ catalogObservedAtDisplay }}
</span>
@@ -249,57 +268,59 @@ watch(search, () => {
</div>
</ZFormItem>
<template v-if="restricted">
<ZInput v-model="search" :aria-label="t('accounts.modelAccess.searchOrAddModels')" :placeholder="t('accounts.modelAccess.searchOrEnterIdAndPressEnter')" :disabled="disabled" :aria-invalid="Boolean(inputError)" @keydown.enter.prevent="addModel">
<template #prefix>
<Search class="size-4" aria-hidden="true" />
</template>
</ZInput>
<div class="flex min-w-0 items-center gap-2">
<ZInput ref="searchInput" v-model="search" class="min-w-0 flex-1" :aria-label="t('accounts.modelAccess.searchOrAddModels')" :placeholder="t('accounts.modelAccess.searchOrEnterId')" :disabled="disabled" :aria-invalid="Boolean(inputError)" @keydown.enter="handleSearchEnter">
<template #prefix>
<Search class="size-4" aria-hidden="true" />
</template>
</ZInput>
<ZButton class="shrink-0" :disabled="disabled || !canAdd" @click="addModel">
<Plus class="size-4" aria-hidden="true" />
{{ t('accounts.modelAccess.addModel') }}
</ZButton>
</div>
<p v-if="inputError" class="m-0 text-z-xs text-z-error-text" role="alert">
{{ inputError }}
</p>
<ZScrollbar v-if="models.length" max-height="15rem" class="min-w-0 -m-1">
<div class="grid grid-cols-2 gap-2 p-1 sm:grid-cols-3" role="group" :aria-label="t('accounts.modelAccess.selectModel')" :aria-busy="loading || undefined">
<div
v-for="item in models"
:key="item.id"
class="flex min-w-0 items-center rounded-z transition-colors duration-150 motion-reduce:transition-none"
:class="model?.models.includes(item.id) ? 'bg-z-primary-container text-z-primary-on-container' : 'bg-z-fill-quaternary text-z-text-secondary hover:bg-z-fill-tertiary hover:text-z-text'"
>
<AccountModelOption
:model-value="model?.models.includes(item.id) ?? false"
:label="item.id"
:disabled="disabled"
@update:model-value="select(item.id, $event)"
/>
<ZPopover v-if="item.unavailable" class="mr-1 shrink-0" trigger="hover-click" placement="top" role="tooltip">
<template #reference="{ open, id }">
<ZIconButton
variant="link"
size="small"
:aria-label="t('accounts.modelAccess.notInCurrentModelCatalog', { id: item.id })"
:aria-describedby="open ? id : undefined"
:aria-expanded="open"
:title="undefined"
>
<TriangleAlert class="size-3.5 text-z-warning-text" aria-hidden="true" />
</ZIconButton>
</template>
<div class="max-w-64 space-y-1 px-3 py-2 text-z-sm wrap-anywhere">
<p class="m-0 font-emphasis text-z-text">
{{ item.id }}
</p>
<p class="m-0 text-z-text-secondary">
{{ t('accounts.modelAccess.modelCatalogMissingHint') }}
</p>
<ZScrollbar v-if="groups.length" max-height="15rem" class="min-w-0 -m-1">
<div class="grid gap-3 p-1" :aria-busy="loading || undefined">
<div v-for="group in groups" :key="group.key" role="group" :aria-label="group.label">
<div class="mb-1.5 flex items-center justify-between gap-2">
<span class="text-z-xs font-medium text-z-text-secondary">{{ group.label }}</span>
<ZIconButton
v-if="group.key === 'catalog'"
type="primary"
variant="link"
size="small"
class="-my-1 size-6!"
:aria-label="allResultsSelected ? t('accounts.modelAccess.deselectAll') : t('accounts.modelAccess.selectAll')"
:disabled="disabled || loading"
@click="select(group.ids, !allResultsSelected)"
>
<ListMinus v-if="allResultsSelected" aria-hidden="true" />
<ListChecks v-else aria-hidden="true" />
</ZIconButton>
</div>
<div class="grid grid-cols-2 gap-2 @xl:grid-cols-3 @3xl:grid-cols-4">
<div
v-for="id in group.ids"
:key="id"
class="flex min-w-0 items-center rounded-z transition-colors duration-150 motion-reduce:transition-none"
:class="selectedIds.has(id) ? 'bg-z-primary-container text-z-primary-on-container' : 'bg-z-fill-quaternary text-z-text-secondary hover:bg-z-fill-tertiary hover:text-z-text'"
>
<AccountModelOption :model-value="selectedIds.has(id)" :label="id" :disabled="disabled" @update:model-value="select([id], $event)" />
</div>
</ZPopover>
</div>
</div>
</div>
</ZScrollbar>
<p v-else-if="loading" class="m-0 py-4 text-center text-z-sm text-z-text-tertiary" role="status">
{{ t('accounts.modelAccess.loadingModel') }}
</p>
<ZEmpty v-else-if="!error" :title="search ? t('accounts.modelAccess.noMatchingModels') : t('accounts.modelAccess.noModels')" :icon="search ? Search : undefined" size="small" surface="none" />
<p v-else-if="mode === 'denylist' && canAdd" class="m-0 text-z-xs text-z-text-secondary">
{{ t('accounts.modelAccess.addToDenylistHint') }}
</p>
<ZEmpty v-else-if="!error && !search" :title="t('accounts.modelAccess.noModels')" size="small" surface="none" />
</template>
</div>
</template>
@@ -427,10 +427,10 @@ export function useAccountConnectionTest(options: { reload: () => Promise<unknow
)
if (!modelsRequest.isCurrent(requestId))
return
applyConnectionTestModels(result, refresh)
applyConnectionTestModels(result.models, account, refresh)
if (refresh) {
ZNotification.success({
message: t('accounts.connectionTest.modelsRefreshed', { count: connectionTestModelOptions.value.length }),
message: t('accounts.connectionTest.modelsRefreshed', { count: result.models.length }),
})
}
else if (!connectionTestSelectedModel.value) {
@@ -441,28 +441,25 @@ export function useAccountConnectionTest(options: { reload: () => Promise<unknow
if (!modelsRequest.isCurrent(requestId))
return
connectionTestError.value = errorMessage(error)
if (!refresh) {
connectionTestModelOptions.value = []
connectionTestSelectedModel.value = ''
}
if (!refresh)
applyConnectionTestModels([], account)
}
finally {
modelsRequest.finish(requestId)
}
}
function applyConnectionTestModels(result: AccountModelsResponse, preserveSelection = false) {
function applyConnectionTestModels(catalog: AccountModelsResponse['models'], account: Account, preserveSelection = false) {
const previousSelection = preserveSelection ? connectionTestSelectedModel.value : ''
connectionTestModelOptions.value = []
for (const model of result.models ?? []) {
connectionTestModelOptions.value.push({
label: model.label || model.id,
value: model.id,
})
const labels = new Map(catalog.map(model => [model.id, model.label || model.id]))
if (account.modelAccess.mode === 'allowlist') {
for (const id of account.modelAccess.models) {
if (!labels.has(id))
labels.set(id, id)
}
}
connectionTestSelectedModel.value = connectionTestModelOptions.value.some(
model => model.value === previousSelection,
)
connectionTestModelOptions.value = Array.from(labels, ([value, label]) => ({ value, label }))
connectionTestSelectedModel.value = labels.has(previousSelection)
? previousSelection
: connectionTestModelOptions.value[0]?.value || ''
}