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