Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/guides/user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
2 changes: 2 additions & 0 deletions docs/guides/user-guide.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
3. 对视频模型检查适配方式、支持的模式、分辨率和时长。
4. 保存模型,然后在生成面板或对话框中选择它。

在智谱 BigModel 大陆端点添加其他类别的受支持模型时,可选择 **复用已保存连接**。用服务卡片上的短连接 ID 区分账号。同一地址下,直接填写新密钥并另存为新连接即可添加另一个账号。更改所选模型或移除服务只影响当前类别;更换共享密钥会更新使用该账号的所有类别。

配置时可按下面的说明核对字段:

| 字段 | 如何填写 |
Expand Down
20 changes: 10 additions & 10 deletions packages/core/src/opendirector/application/model_catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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)
13 changes: 13 additions & 0 deletions packages/core/src/opendirector/domain/model_connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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('无效的接入方式。')
Expand Down
56 changes: 43 additions & 13 deletions packages/core/src/opendirector/infrastructure/model_configs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)

Expand All @@ -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'))
Expand All @@ -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)

Expand Down Expand Up @@ -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'))
Expand All @@ -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]

Expand Down
47 changes: 30 additions & 17 deletions src/canvas/ModelServiceForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { ModelCapabilityCard } from "./ModelCapabilityCard";
import {
PROVIDER_PRESETS,
matchingConnections,
isBigModelConnection,
providerPresetFor,
} from "./providerPresets";
import { CanvasIcon } from "./CanvasIcon";
Expand Down Expand Up @@ -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() &&
Expand Down Expand Up @@ -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({});
Expand All @@ -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
Expand Down Expand Up @@ -399,6 +404,7 @@ export function ModelServiceForm({
{t("models.service.reuseConnection", {
model: model.modelId,
})}
{isBigModelConnection(model) && ` · ${(model.providerId ?? model.id).slice(0, 8)}`}
</button>
))}
<small>{t("models.service.reuseHint")}</small>
Expand Down Expand Up @@ -515,6 +521,13 @@ export function ModelServiceForm({
{showKey ? t("models.service.hide") : t("models.service.show")}
</button>
</div>
{connection && isBigModelConnection(connection) && isBigModelConnection(value) && (
<p className="cw-model-note">
{t("models.service.sharedBigModelAccount", {
id: (connection.providerId ?? connection.id).slice(0, 8),
})}
</p>
)}
</fieldset>
{["minimax_video_v2", "kling_video"].includes(value.protocol) && (
<p className="cw-model-note">{t("models.service.minimaxNote")}</p>
Expand Down
5 changes: 3 additions & 2 deletions src/canvas/ModelSettings.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -187,7 +187,8 @@ export function ModelSettings({ catalog, onClose, guided = false }: { catalog: B
</div>
<div className="cw-model-service-bottom">
<span>
{providerLabel(model)}{" "}
{providerLabel(model)}
{isBigModelConnection(model) && ` · ${(model.providerId ?? model.id).slice(0, 8)}`}{" "}
·{" "}
{model.authMode === "none"
? t("models.service.noKey")
Expand Down
Loading
Loading