diff --git a/docs/guides/user-guide.md b/docs/guides/user-guide.md index 0faf8aff..9ab8ad1a 100644 --- a/docs/guides/user-guide.md +++ b/docs/guides/user-guide.md @@ -13,6 +13,8 @@ For optional Kling 4.0 and Flash video generation, follow [Generate videos with 3. For video models, check the adapter, supported modes, resolutions, and durations. 4. Save the model, then select it in the generation panel or chat. +For the mainland Zhipu BigModel endpoint, select **Reuse saved connection** when adding a supported model in another category. Match the short connection ID shown on its service card to choose the account. To add a separate account at the same address, enter its key as a new connection. Changing the selected models or removing the service affects only the current category. Replacing a shared key updates the categories using that account. + Use the following guide to check the configuration fields: | Field | What to enter | diff --git a/docs/guides/user-guide.zh-CN.md b/docs/guides/user-guide.zh-CN.md index 31be205f..c9e94607 100644 --- a/docs/guides/user-guide.zh-CN.md +++ b/docs/guides/user-guide.zh-CN.md @@ -13,6 +13,8 @@ 3. 对视频模型检查适配方式、支持的模式、分辨率和时长。 4. 保存模型,然后在生成面板或对话框中选择它。 +在智谱 BigModel 大陆端点添加其他类别的受支持模型时,可选择 **复用已保存连接**。用服务卡片上的短连接 ID 区分账号。同一地址下,直接填写新密钥并另存为新连接即可添加另一个账号。更改所选模型或移除服务只影响当前类别;更换共享密钥会更新使用该账号的所有类别。 + 配置时可按下面的说明核对字段: | 字段 | 如何填写 | diff --git a/packages/core/src/opendirector/application/model_catalog.py b/packages/core/src/opendirector/application/model_catalog.py index d5df3a3c..e2cccdbd 100644 --- a/packages/core/src/opendirector/application/model_catalog.py +++ b/packages/core/src/opendirector/application/model_catalog.py @@ -7,7 +7,7 @@ import time from opendirector.domain.input_rules import input_rules -from opendirector.domain.model_connection import connection_fields, model_fields +from opendirector.domain.model_connection import connection_fields, model_fields, same_connection_address from opendirector.domain.projects import ProjectError from opendirector.integrations.chat_completions import ChatCompletions, chat_payload from opendirector.integrations.model_capabilities import read_service_models, read_video_capabilities @@ -23,7 +23,7 @@ def _draft(kind, url, model_id, api_key, connection_type, protocol, auth_mode, p def _reuse_saved_key(config, existing, *, require_key): - if not existing or existing['url'] != config['url'] or (require_key and not existing['apiKey']): + if not existing or not same_connection_address(existing, config) or (require_key and not existing['apiKey']): raise ProjectError(_SAVED_KEY) config['apiKey'] = existing['apiKey'] return config @@ -51,32 +51,32 @@ def delete(self, config_id): def delete_service(self, config_id): self.configs.delete_service(config_id) - async def _existing(self, config_id, kind, *, threaded): + async def _existing(self, config_id, config, *, threaded): if not config_id: return None # Discovery and agent tests read the saved key off the event loop. # Capability lookup stays on the caller, as the previous handler did. if threaded: - return await asyncio.to_thread(self.configs.resolve, config_id, kind) - return self.configs.resolve(config_id, kind) + return await asyncio.to_thread(self.configs.resolve, config_id, config['kind'], connection=config) + return self.configs.resolve(config_id, config['kind'], connection=config) - async def _with_saved_key(self, config, config_id, kind, *, require_key, threaded): + async def _with_saved_key(self, config, config_id, *, require_key, threaded): if config['authMode'] != 'bearer' or config['apiKey']: return config - existing = await self._existing(config_id, kind, threaded=threaded) + existing = await self._existing(config_id, config, threaded=threaded) return _reuse_saved_key(config, existing, require_key=require_key) async def discover(self, kind, url, model_id, api_key, config_id=None, *, connection_type='api', protocol=None, auth_mode=None, proxy_mode=None): config = _draft(kind, url, model_id or 'discovery', api_key, connection_type, protocol, auth_mode, proxy_mode) - config = await self._with_saved_key(config, config_id, kind, require_key=True, threaded=True) + config = await self._with_saved_key(config, config_id, require_key=True, threaded=True) models = await read_service_models(config) return list(dict.fromkeys(item['id'] for item in models)) async def test_agent(self, url, model_id, api_key, config_id=None, *, connection_type='api', protocol=None, auth_mode=None, proxy_mode=None, timeout=AGENT_TEST_TIMEOUT_SECONDS): config = _draft('agent', url, model_id, api_key, connection_type, protocol, auth_mode, proxy_mode) - config = await self._with_saved_key(config, config_id, 'agent', require_key=True, threaded=True) + config = await self._with_saved_key(config, config_id, require_key=True, threaded=True) payload = chat_payload(config, [{'role': 'user', 'content': 'Reply with OK only.'}], 'auto') payload['max_tokens'] = 1024 started = time.monotonic() @@ -100,5 +100,5 @@ async def capabilities(self, kind, url, model_id, api_key, *, source='preset', c videoAdapter=video_adapter or 'auto') if source == 'preset': return input_rules(config).get('videoCapabilities') - config = await self._with_saved_key(config, config_id, 'video', require_key=False, threaded=False) + config = await self._with_saved_key(config, config_id, require_key=False, threaded=False) return await read_video_capabilities(config) diff --git a/packages/core/src/opendirector/domain/model_connection.py b/packages/core/src/opendirector/domain/model_connection.py index ab196ddc..2675eb5c 100644 --- a/packages/core/src/opendirector/domain/model_connection.py +++ b/packages/core/src/opendirector/domain/model_connection.py @@ -4,6 +4,19 @@ from opendirector.domain.projects import ProjectError +def is_bigmodel_connection(config: dict) -> bool: + """Official mainland BigModel presets share one bearer account across protocols.""" + kind = config.get('kind') + return (config.get('url', '').rstrip('/') == 'https://open.bigmodel.cn/api/paas/v4' + and config.get('connectionType') == 'api' and config.get('authMode') == 'bearer' + and kind in {'agent', 'image', 'video', 'audio'} + and config.get('protocol') == ('bigmodel_video' if kind == 'video' else 'openai')) + + +def same_connection_address(saved: dict, draft: dict) -> bool: + return saved['url'] == draft['url'] or (is_bigmodel_connection(saved) and is_bigmodel_connection(draft)) + + def connection_fields(kind, connection_type='api', protocol=None, auth_mode=None, proxy_mode=None): if connection_type not in {'api', 'self_hosted'}: raise ProjectError('无效的接入方式。') diff --git a/packages/core/src/opendirector/infrastructure/model_configs.py b/packages/core/src/opendirector/infrastructure/model_configs.py index c928272d..17755b6e 100644 --- a/packages/core/src/opendirector/infrastructure/model_configs.py +++ b/packages/core/src/opendirector/infrastructure/model_configs.py @@ -11,13 +11,27 @@ from opendirector.domain.projects import ProjectError, identifier from opendirector.domain.input_rules import input_rules from opendirector.domain.capabilities import agent_input_modalities -from opendirector.domain.model_connection import connection_fields, model_fields +from opendirector.domain.model_connection import connection_fields, is_bigmodel_connection, model_fields, same_connection_address from opendirector.domain.video_capabilities import check_video_config from opendirector.infrastructure.local_projects import child, read_json, write_json MODEL_KINDS = ("agent", "image", "video", "audio", "vision") +def _provider_for_edit(models, existing, draft): + provider_id = existing.get('providerId', existing['id']) + # Moving a category off a shared account must not move other categories' keys or URLs. + if not is_bigmodel_connection(draft) and any( + item.get('providerId') == provider_id and item['kind'] != existing['kind'] for item in models + ): + new_id = str(uuid4()) + for item in models: + if item.get('providerId') == provider_id and item['kind'] == existing['kind']: + item['providerId'] = new_id + return new_id + return provider_id + + class ModelConfigs: def __init__(self, repository: Path): self.directory = repository.resolve() / ".opendirector" @@ -80,10 +94,12 @@ def agent(self, config_id: str) -> dict: """Resolve a private credential snapshot for a backend call only.""" return self.resolve(config_id, "agent") - def resolve(self, config_id: str, kind: str) -> dict: + def resolve(self, config_id: str, kind: str, *, connection: dict | None = None) -> dict: with self.lock: - item = next((item for item in self._read() if item["id"] == config_id and item["kind"] == kind), None) - if item is None: + item = next((item for item in self._read() if item["id"] == config_id), None) + if item is None or (item["kind"] != kind and not ( + connection and is_bigmodel_connection(item) and is_bigmodel_connection(connection) + )): raise ProjectError("所选模型不存在或类别不符,请在设置中配置并重新选择。", 409) return dict(item) @@ -104,11 +120,14 @@ def save(self, kind: str, url: str, model_id: str, api_key: str, config_id: str legacy_segmentation = existing and existing['kind'] == 'vision' and kind in {'image', 'video'} if existing and existing["kind"] != kind and not legacy_segmentation: raise ProjectError("不能修改模型所属类别。") + draft = {'kind': kind, **fields, **connection} if connection['authMode'] == 'none': fields['apiKey'] = '' elif not fields['apiKey']: if not existing or not existing['apiKey']: raise ProjectError('请填写 API Key,或将鉴权方式设为无需鉴权。') + if not same_connection_address(existing, draft): + raise ProjectError('请填写该服务的 API Key;新地址不能复用原地址的密钥。') fields['apiKey'] = existing['apiKey'] item = {'id': config_id or str(uuid4()), 'kind': kind, **fields, **connection} item['inputModalities'] = agent_input_modalities(kind, input_modalities if input_modalities is not None else (existing or {}).get('inputModalities')) @@ -118,15 +137,18 @@ def save(self, kind: str, url: str, model_id: str, api_key: str, config_id: str profile = input_rules(item)['profile'] if item['videoCapabilities'] is not None and profile in {'fasth3-omni', 'vllm-omni-h3'}: check_video_config(kind, 'vllm_omni', 'fasth3' if profile == 'fasth3-omni' else 'minimax_h3', item['videoCapabilities']) + if existing and 'providerId' in existing: + item['providerId'] = _provider_for_edit(models, existing, item) if existing: models[models.index(existing)] = item else: models.append(item) - if existing and 'providerId' in existing: - item['providerId'] = existing['providerId'] + if 'providerId' in item: for peer in models: if peer.get('providerId') == item['providerId']: - peer.update({key: item[key] for key in ('url', 'apiKey', 'connectionType', 'authMode', 'proxyMode')}) + peer.update({key: item[key] for key in ('url', 'apiKey', 'connectionType', 'authMode')}) + if peer['kind'] == kind: + peer['proxyMode'] = item['proxyMode'] self._write(models) return self._public(item) @@ -168,25 +190,33 @@ def save_many(self, kind: str, url: str, model_ids: list[str], api_key: str, if config_id and not existing: raise ProjectError('模型配置已被删除,请重新加载。', 404) legacy_segmentation = existing and existing['kind'] == 'vision' and kind in {'image', 'video'} - if existing and existing['kind'] != kind and not legacy_segmentation: + draft = {'kind': kind, **fields[0], **connection} + cross_category = existing and existing['kind'] != kind and not legacy_segmentation + if cross_category and not (is_bigmodel_connection(existing) and is_bigmodel_connection(draft)): raise ProjectError('不能修改模型所属类别。') key = api_key.strip() if connection['authMode'] == 'none': key = '' elif not key: - if not existing or existing['url'] != fields[0]['url'] or not existing['apiKey']: + if not existing or not same_connection_address(existing, draft) or not existing['apiKey']: raise ProjectError('请填写该服务的 API Key;新地址不能复用原地址的密钥。') key = existing['apiKey'] - provider_id = existing.get('providerId', existing['id']) if existing else str(uuid4()) - previous = [item for item in models if item.get('providerId') == provider_id or item['id'] == config_id] + provider_id = _provider_for_edit(models, existing, draft) if existing else str(uuid4()) + if cross_category: + # Promote an explicitly selected standalone record, preserving its model ID. + existing['providerId'] = provider_id + previous = [item for item in models if (item.get('providerId') == provider_id and item['kind'] == kind) + or (item['id'] == config_id and not cross_category)] peers = {item['modelId']: item for item in previous} saved = [] for field in fields: old = peers.get(field['modelId']) if old is None and len(previous) == 1 and len(fields) == 1: - old = existing + old = previous[0] item = {'id': old['id'] if old else str(uuid4()), 'kind': kind, **field, **connection, 'apiKey': key, 'providerId': provider_id, 'videoAdapter': video_adapter or 'auto'} + if cross_category and proxy_mode is None and previous: + item['proxyMode'] = (old or previous[0])['proxyMode'] options = model_options.get(field['modelId'], {}) item['videoAdapter'] = options.get('videoAdapter') or video_adapter or (old or {}).get('videoAdapter', 'auto') modalities = options.get('inputModalities', input_modalities if input_modalities is not None else (old or {}).get('inputModalities')) @@ -200,7 +230,7 @@ def save_many(self, kind: str, url: str, model_ids: list[str], api_key: str, remaining = [item for item in models if item not in previous] for item in remaining: if item.get('providerId') == provider_id: - item.update(url=fields[0]['url'], apiKey=key, connectionType=connection['connectionType'], authMode=connection['authMode'], proxyMode=connection['proxyMode']) + item.update(url=fields[0]['url'], apiKey=key, connectionType=connection['connectionType'], authMode=connection['authMode']) self._write(remaining + saved) return [self._public(item) for item in saved] diff --git a/src/canvas/ModelServiceForm.tsx b/src/canvas/ModelServiceForm.tsx index c3d775ad..2eeaaf2d 100644 --- a/src/canvas/ModelServiceForm.tsx +++ b/src/canvas/ModelServiceForm.tsx @@ -18,6 +18,7 @@ import { ModelCapabilityCard } from "./ModelCapabilityCard"; import { PROVIDER_PRESETS, matchingConnections, + isBigModelConnection, providerPresetFor, } from "./providerPresets"; import { CanvasIcon } from "./CanvasIcon"; @@ -159,7 +160,7 @@ export function ModelServiceForm({ const hasSavedKey = Boolean( connection && connection.authMode === "bearer" && - connection.url === value.url.trim(), + (connection.url === value.url.trim() || (isBigModelConnection(connection) && isBigModelConnection(value))), ); const canFetch = Boolean( value.url.trim() && @@ -306,28 +307,31 @@ export function ModelServiceForm({ }; const reuseConnection = (model: ModelConfig) => { setConnection(model); + const peers = catalog.models.filter( + (item) => + item.kind === kind && + (model.providerId ? item.providerId === model.providerId : item.id === model.id), + ); + const target = peers[0]; setValue({ - kind, + ...value, url: model.url, - modelId: model.modelId, + modelId: target?.modelId ?? "", connectionType: model.connectionType, - inputModalities: model.inputModalities, - videoAdapter: model.videoAdapter, - videoCapabilities: model.videoCapabilities, - protocol: model.protocol ?? PROTOCOLS[kind][0][0], + inputModalities: target?.inputModalities, + videoAdapter: target?.videoAdapter ?? "auto", + videoCapabilities: target?.videoCapabilities, + protocol: target?.protocol ?? value.protocol, authMode: model.authMode ?? "bearer", - proxyMode: model.proxyMode ?? "system", + proxyMode: target?.proxyMode ?? model.proxyMode ?? "system", apiKey: "", }); - const peers = catalog.models.filter( - (item) => - item.kind === kind && - (model.providerId - ? item.providerId === model.providerId - : item.id === model.id), - ); - setSelectedModels(peers.map((item) => item.modelId)); - setModelIds(peers.map((item) => item.modelId)); + const available = PROVIDER_PRESETS.find((preset) => preset.id === presetId)?.modelsByKind?.[kind] ?? []; + const selected = target ? peers.map((item) => item.modelId) : [...available]; + setSelectedModels(selected); + setModelIds([...new Set([...selected, ...available])]); + setLoaded(false); + setDiscoveryError(""); setModelOptions({}); setModelErrors({}); setResults({}); @@ -338,6 +342,7 @@ export function ModelServiceForm({ catalog.models.find( (model) => model.modelId === id && + (model.kind === kind || (model.kind === "vision" && model.id === existing?.id)) && connection && (connection.providerId ? model.providerId === connection.providerId @@ -399,6 +404,7 @@ export function ModelServiceForm({ {t("models.service.reuseConnection", { model: model.modelId, })} + {isBigModelConnection(model) && ` · ${(model.providerId ?? model.id).slice(0, 8)}`} ))} {t("models.service.reuseHint")} @@ -515,6 +521,13 @@ export function ModelServiceForm({ {showKey ? t("models.service.hide") : t("models.service.show")} + {connection && isBigModelConnection(connection) && isBigModelConnection(value) && ( +

+ {t("models.service.sharedBigModelAccount", { + id: (connection.providerId ?? connection.id).slice(0, 8), + })} +

+ )} {["minimax_video_v2", "kling_video"].includes(value.protocol) && (

{t("models.service.minimaxNote")}

diff --git a/src/canvas/ModelSettings.tsx b/src/canvas/ModelSettings.tsx index 060bd2de..9ab4c207 100644 --- a/src/canvas/ModelSettings.tsx +++ b/src/canvas/ModelSettings.tsx @@ -1,6 +1,6 @@ import { ModelSetup, modelSetupMissing, type SetupKind } from "./ModelSetup"; import { ModelLogo } from "./ModelLogo"; -import { providerLabel } from "./providerPresets"; +import { isBigModelConnection, providerLabel } from "./providerPresets"; import { useState } from "react"; import { errorMessage } from "../backend/api.ts"; import { MODEL_KINDS, type BackendModels } from "../backend/models.ts"; @@ -187,7 +187,8 @@ export function ModelSettings({ catalog, onClose, guided = false }: { catalog: B
- {providerLabel(model)}{" "} + {providerLabel(model)} + {isBigModelConnection(model) && ` · ${(model.providerId ?? model.id).slice(0, 8)}`}{" "} ·{" "} {model.authMode === "none" ? t("models.service.noKey") diff --git a/src/canvas/providerPresets.ts b/src/canvas/providerPresets.ts index 08bea610..3902ba3b 100644 --- a/src/canvas/providerPresets.ts +++ b/src/canvas/providerPresets.ts @@ -64,6 +64,8 @@ export interface ProviderPreset { modelsByKind?: Partial>; } +const BIGMODEL_URL = "https://open.bigmodel.cn/api/paas/v4"; + export const PROVIDER_PRESETS: ProviderPreset[] = [ { id: "custom", @@ -136,7 +138,7 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [ logoOrganization: "zai-org", name: "智谱 BigModel", kinds: ["agent"], - url: "https://open.bigmodel.cn/api/paas/v4", + url: BIGMODEL_URL, protocol: "openai", connectionType: "api", authMode: "bearer", @@ -264,17 +266,30 @@ export function providerLabel(model: ModelConfig) { ); } +export function isBigModelConnection( + model: Pick, +): boolean { + return ( + model.url.replace(/\/+$/, "") === BIGMODEL_URL && + model.connectionType === "api" && model.authMode === "bearer" && + model.kind !== "vision" && model.protocol === (model.kind === "video" ? "bigmodel_video" : "openai") + ); +} + export function matchingConnections( models: ModelConfig[], kind: ModelKind, value: ModelConfigInput, ): ModelConfig[] { const seen = new Set(); + const bigModel = isBigModelConnection(value); return models.filter((model) => { const id = model.providerId ?? model.id; + const compatible = bigModel + ? isBigModelConnection(model) + : model.kind === kind && model.protocol === value.protocol; if ( - model.kind !== kind || - model.protocol !== value.protocol || + !compatible || model.url.replace(/\/+$/, "") !== value.url.replace(/\/+$/, "") || seen.has(id) ) diff --git a/src/i18n/messages/en-US/models.ts b/src/i18n/messages/en-US/models.ts index 6e2d6c99..018fedbb 100644 --- a/src/i18n/messages/en-US/models.ts +++ b/src/i18n/messages/en-US/models.ts @@ -122,6 +122,7 @@ export const models = { "models.service.existingConnections": "Connections with the same address", "models.service.reuseConnection": "Reuse saved connection · {model}", + "models.service.sharedBigModelAccount": "BigModel connection {id}. A replacement key applies to every category using this account.", "models.service.reuseHint": "Otherwise this will be saved as a separate connection.", "models.service.loadSupportedModels": "Load supported models", diff --git a/src/i18n/messages/zh-CN/models.ts b/src/i18n/messages/zh-CN/models.ts index 2e9c132c..8d0f0980 100644 --- a/src/i18n/messages/zh-CN/models.ts +++ b/src/i18n/messages/zh-CN/models.ts @@ -100,6 +100,7 @@ export const models = { "models.service.advancedConnection": "高级连接设置", "models.service.existingConnections": "已有相同地址的连接", "models.service.reuseConnection": "复用已保存连接 · {model}", + "models.service.sharedBigModelAccount": "智谱 BigModel 连接 {id}。更换密钥会更新使用此账号的所有类别。", "models.service.reuseHint": "不复用则保存为独立连接。", "models.service.loadSupportedModels": "载入支持的模型", "models.service.minimaxNote": diff --git a/tests/providerPresets.test.ts b/tests/providerPresets.test.ts index 6c66bf57..2dd822d0 100644 --- a/tests/providerPresets.test.ts +++ b/tests/providerPresets.test.ts @@ -175,3 +175,16 @@ test("MiniMax H3 image preset exposes both models in both display languages", () setActiveLocale(previous); } }); + +test("BigModel connections are offered per account across categories", () => { + const preset = PROVIDER_PRESETS.find((item) => item.id === "zhipu")!; + const account: ModelConfig = { ...preset, kind: "agent", modelId: "shared-model", id: "old-chat", + inputRules: { profile: "agent", allowedKinds: [], maxImages: 0, maxVideos: 0, maxAudios: 0, videoModes: [] } }; + const target = { ...preset, modelId: "image", kind: "image" as const, apiKey: "", url: preset.url + "/" }; + const records: ModelConfig[] = [account, { ...account, id: "two", providerId: "other" }, + { ...account, kind: "audio", providerId: "other", id: "speech" }, + { ...account, id: "without-key", authMode: "none" }, + { ...account, id: "custom", url: preset.url + "/other" }]; + assert.deepEqual(matchingConnections(records, "image", target).map(item => item.id), ["old-chat", "two"]); + assert.deepEqual(matchingConnections(records, "image", { ...target, url: "https://models.example/v1" }), []); +}); diff --git a/tests/test_model_catalog.py b/tests/test_model_catalog.py index b28832d7..ce929a39 100644 --- a/tests/test_model_catalog.py +++ b/tests/test_model_catalog.py @@ -17,7 +17,7 @@ def __init__(self, existing=None): self.existing = existing self.calls = [] - def resolve(self, config_id, kind): + def resolve(self, config_id, kind, *, connection=None): self.calls.append((config_id, kind)) if isinstance(self.existing, Exception): raise self.existing @@ -216,3 +216,25 @@ def test_preferences_round_trip_through_the_settings_store(tmp_path): assert service.get() == {'removeWatermark': True} assert service.update({'removeWatermark': False}) == {'removeWatermark': False} assert Preferences(tmp_path).get() == {'removeWatermark': False} + + +def test_bigmodel_probes_reuse_only_the_selected_saved_account(tmp_path, monkeypatch): + configs = ModelConfigs(tmp_path) + url = 'https://open.bigmodel.cn/api/paas/v4' + first, second = [configs.save_many('image', url + '/', ['image'], key, protocol='openai')[0] + for key in ['fixture-first', 'fixture-second']] + calls, read = _reader([{'id': 'glm-test'}]) + monkeypatch.setattr('opendirector.application.model_catalog.read_service_models', read) + tester = Scripted([{'type': 'delta', 'text': 'OK'}]) + catalog = ModelCatalog(configs, tester) + before = configs.path.read_bytes() + assert asyncio.run(catalog.discover('audio', url, '', '', second['id'], protocol='openai')) == ['glm-test'] + asyncio.run(catalog.test_agent(url, 'glm-test', '', first['id'], protocol='openai')) + assert calls[0]['apiKey'] == 'fixture-second' + assert tester.calls[0][0]['apiKey'] == 'fixture-first' + with pytest.raises(ProjectError): + asyncio.run(catalog.discover('audio', url + '/elsewhere', '', '', second['id'], protocol='openai')) + with pytest.raises(ProjectError): + configs.resolve(first['id'], 'agent') + assert len(calls) == 1 + assert configs.path.read_bytes() == before diff --git a/tests/test_model_configs.py b/tests/test_model_configs.py index 46cd5bcf..d000c11c 100644 --- a/tests/test_model_configs.py +++ b/tests/test_model_configs.py @@ -458,3 +458,102 @@ def test_batch_video_cards_keep_distinct_adapter_and_output_settings(api): first, second = response.json() assert first['videoCapabilities']['outputs'][0]['resolution'] == '540P' assert second['videoCapabilities'] is None and second['inputRules']['profile'] == 'vllm-omni-h3' + + +def test_bigmodel_shared_account_keeps_categories_and_accounts_separate(api): + client, store = api + url = 'https://open.bigmodel.cn/api/paas/v4' + def batch(kind, models, key='', anchor=None, **opts): + return client.post('/api/model-configs/batch', json=payload( + kind, url=url, protocol='openai', authMode='bearer', connectionType='api', + modelIds=models, apiKey=key, configId=anchor, **opts)) + + # An older single-model account and an independent account on the same URL. + chat = client.post('/api/model-configs', json=payload( + 'agent', url=url + '/', modelId='shared-name', apiKey='fixture-first', protocol='openai')).json() + other = batch('agent', ['shared-name'], 'fixture-second').json()[0] + response = batch('image', ['shared-name', 'image-two'], anchor=chat['id']) + assert response.status_code == 200, response.text + image, second_image = response.json() + assert image['id'] != chat['id'] + assert image['providerId'] == second_image['providerId'] == chat['id'] + audio = batch('audio', ['voice'], anchor=image['id']).json()[0] + other_image = batch('image', ['shared-name'], anchor=other['id']).json()[0] + path = store.state_dir / 'models.json' + assert path.read_text().count('fixture-first') == path.read_text().count('fixture-second') == 1 + configs = ModelConfigs(store.repository) + assert configs.resolve(chat['id'], 'agent')['providerId'] == audio['providerId'] + assert configs.resolve(audio['id'], 'audio')['apiKey'] == 'fixture-first' + assert configs.resolve(other_image['id'], 'image')['apiKey'] == 'fixture-second' + + # Saving through a different category's anchor must reuse the destination ID. + edited = batch('image', ['image-two'], anchor=audio['id']).json()[0] + assert edited['id'] == second_image['id'] + assert batch('audio', ['new-voice'], 'fixture-rotated', anchor=audio['id']).json()[0]['id'] == audio['id'] + assert configs.resolve(chat['id'], 'agent')['apiKey'] == 'fixture-rotated' + assert configs.resolve(other['id'], 'agent')['apiKey'] == 'fixture-second' + assert client.delete('/api/model-services/' + edited['id']).status_code == 204 + assert {item['id'] for item in configs.list()} == {chat['id'], audio['id'], other['id'], other_image['id']} + assert 'fixture-' not in client.get('/api/model-configs').text + + before = path.read_bytes() + bad = client.post('/api/model-configs/batch', json=payload('image', + url='https://other.test/v4', protocol='openai', modelIds=['wrong'], configId=chat['id'], apiKey='')) + assert bad.status_code == 400 + assert path.read_bytes() == before + + +@pytest.mark.parametrize('edit', ['batch', 'single']) +def test_changing_shared_bigmodel_endpoint_moves_only_the_edited_category(api, edit): + client, store = api + url = 'https://open.bigmodel.cn/api/paas/v4' + chat = client.post('/api/model-configs/batch', json=payload( + 'agent', url=url, protocol='openai', modelIds=['chat'])).json()[0] + images = client.post('/api/model-configs/batch', json=payload( + 'image', url=url, protocol='openai', configId=chat['id'], apiKey='', + modelIds=['one', 'two'])).json() + configs = ModelConfigs(store.repository) + data = payload('image', url='https://another.example/v1', modelId='one', protocol='openai', apiKey='') + def update(): + if edit == 'batch': + return client.post('/api/model-configs/batch', json={ + **data, 'modelIds': ['one', 'two'], 'configId': images[0]['id']}) + return client.patch('/api/model-configs/' + images[0]['id'], json=data) + before = configs.path.read_bytes() + assert update().status_code == 400 + assert configs.path.read_bytes() == before + data['apiKey'] = 'fixture-new-endpoint' + assert update().status_code == 200 + for row in images: + saved = configs.resolve(row['id'], 'image') + assert saved['url'] == data['url'] and saved['apiKey'] == 'fixture-new-endpoint' + assert saved['providerId'] != chat['providerId'] + assert configs.resolve(chat['id'], 'agent')['apiKey'] == 'test-secret' + assert configs.resolve(chat['id'], 'agent')['url'] == url + + +def test_bigmodel_batch_with_another_category_anchor_preserves_destination_proxy(api): + client, store = api + url = 'https://open.bigmodel.cn/api/paas/v4' + def batch(kind, models, **options): + response = client.post('/api/model-configs/batch', json=payload( + kind, url=url, protocol='openai', modelIds=models, **options)) + assert response.status_code == 200, response.text + return response.json() + + anchor = batch('agent', ['chat'], proxyMode='system')[0]['id'] + first = batch('image', ['one', 'two'], configId=anchor, apiKey='', proxyMode='direct') + updated = batch('image', ['one', 'two', 'three'], configId=anchor, apiKey='') + assert [item['id'] for item in updated[:2]] == [item['id'] for item in first] + assert [item['proxyMode'] for item in updated] == ['direct'] * 3 + configs = ModelConfigs(store.repository) + assert configs.resolve(anchor, 'agent')['proxyMode'] == 'system' + assert [configs.resolve(item['id'], 'image')['proxyMode'] for item in updated] == ['direct'] * 3 + # Version-1 files store proxyMode on each row, even when credentials are shared. + stored = json.loads(configs.path.read_text()) + next(item for item in stored['models'] if item['id'] == first[1]['id'])['proxyMode'] = 'system' + configs.path.write_text(json.dumps(stored)) + mixed = batch('image', ['one', 'two', 'three'], configId=anchor, apiKey='') + assert [item['proxyMode'] for item in mixed] == ['direct', 'system', 'direct'] + assert [item['proxyMode'] for item in batch('image', ['one', 'two'], configId=anchor, apiKey='', + proxyMode='system')] == ['system', 'system']