feat: support aggregated image model protocols
This commit is contained in:
@@ -1365,6 +1365,12 @@ textarea:focus-visible,
|
|||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.model-input > small {
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-size: 8px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
.model-input input,
|
.model-input input,
|
||||||
.model-input select,
|
.model-input select,
|
||||||
.routing-row select {
|
.routing-row select {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
ImageParameterProfile,
|
||||||
ModelCapability,
|
ModelCapability,
|
||||||
ModelCategory,
|
ModelCategory,
|
||||||
ModelPoolItem,
|
ModelPoolItem,
|
||||||
@@ -52,6 +53,30 @@ const providerLabels: Record<string, string> = {
|
|||||||
custom: "其他兼容接口",
|
custom: "其他兼容接口",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const imagePresets: Record<Exclude<ImageParameterProfile, "generic">, Partial<ModelPoolItem>> = {
|
||||||
|
gpt_image_2: {
|
||||||
|
name: "GPT Image 2", model_id: "gpt-image-2", category: "multimodal", provider: "lingke",
|
||||||
|
base_url: "https://api.lk888.ai", capabilities: ["image_generation", "image_editing"],
|
||||||
|
models_path: "/v1/models", image_generation_path: "/v1/media/generate",
|
||||||
|
image_edit_path: "/v1/media/generate", image_status_path: "/v1/media/status",
|
||||||
|
image_protocol: "aigc_media", image_parameter_profile: "gpt_image_2",
|
||||||
|
},
|
||||||
|
nano_banana_pro: {
|
||||||
|
name: "Nano Banana Pro", model_id: "gemini-3-pro-image-preview", category: "multimodal", provider: "lingke",
|
||||||
|
base_url: "https://api.lk888.ai", capabilities: ["image_generation", "image_editing"],
|
||||||
|
models_path: "/v1/models", image_generation_path: "/v1/media/generate",
|
||||||
|
image_edit_path: "/v1/media/generate", image_status_path: "/v1/media/status",
|
||||||
|
image_protocol: "aigc_media", image_parameter_profile: "nano_banana_pro",
|
||||||
|
},
|
||||||
|
seedream_5_pro: {
|
||||||
|
name: "即梦 5.0 Pro", model_id: "doubao-seedream-5-0-pro-260628", category: "multimodal", provider: "lingke",
|
||||||
|
base_url: "https://api.lk888.ai", capabilities: ["image_generation", "image_editing"],
|
||||||
|
models_path: "/v1/models", image_generation_path: "/v1/media/generate",
|
||||||
|
image_edit_path: "/v1/media/generate", image_status_path: "/v1/media/status",
|
||||||
|
image_protocol: "aigc_media", image_parameter_profile: "seedream_5_pro",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
type SettingsCenterProps = {
|
type SettingsCenterProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -211,6 +236,27 @@ export function SettingsCenter({ open, onClose, onReadinessChange }: SettingsCen
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function testModelGeneration(modelId: string) {
|
||||||
|
if (!window.confirm("这会真实生成一张低成本测试图,并消耗该模型的一次调用额度。是否继续?")) return;
|
||||||
|
const target = `generation:${modelId}`;
|
||||||
|
setBusy(target);
|
||||||
|
setNotice(null);
|
||||||
|
try {
|
||||||
|
await saveSettings(false);
|
||||||
|
setBusy(target);
|
||||||
|
const response = await fetch(`${API_BASE}/v1/settings/models/${encodeURIComponent(modelId)}/test-generation`, {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
const result = await response.json() as TestResult;
|
||||||
|
setTestResults((current) => ({ ...current, [target]: result }));
|
||||||
|
setNotice({ tone: result.ok ? "success" : "error", text: result.message });
|
||||||
|
} catch (error) {
|
||||||
|
setNotice({ tone: "error", text: getErrorMessage(error) });
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function generateFor(field: SettingField) {
|
async function generateFor(field: SettingField) {
|
||||||
if (!field.generator) return;
|
if (!field.generator) return;
|
||||||
setBusy(field.key);
|
setBusy(field.key);
|
||||||
@@ -327,6 +373,7 @@ export function SettingsCenter({ open, onClose, onReadinessChange }: SettingsCen
|
|||||||
busy={busy}
|
busy={busy}
|
||||||
onChange={updateField}
|
onChange={updateField}
|
||||||
onTest={(modelId) => void testModel(modelId)}
|
onTest={(modelId) => void testModel(modelId)}
|
||||||
|
onTrial={(modelId) => void testModelGeneration(modelId)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="settings-fields">
|
<div className="settings-fields">
|
||||||
@@ -408,6 +455,7 @@ type ModelPoolPanelProps = {
|
|||||||
busy: string | null;
|
busy: string | null;
|
||||||
onChange: (key: string, value: unknown) => void;
|
onChange: (key: string, value: unknown) => void;
|
||||||
onTest: (modelId: string) => void;
|
onTest: (modelId: string) => void;
|
||||||
|
onTrial: (modelId: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
function newModel(category: ModelCategory): ModelPoolItem {
|
function newModel(category: ModelCategory): ModelPoolItem {
|
||||||
@@ -424,12 +472,15 @@ function newModel(category: ModelCategory): ModelPoolItem {
|
|||||||
chat_path: "/chat/completions",
|
chat_path: "/chat/completions",
|
||||||
image_generation_path: "/images/generations",
|
image_generation_path: "/images/generations",
|
||||||
image_edit_path: "/images/edits",
|
image_edit_path: "/images/edits",
|
||||||
|
image_status_path: "/v1/media/status",
|
||||||
|
image_protocol: "openai_images",
|
||||||
|
image_parameter_profile: "generic",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
api_key_configured: false,
|
api_key_configured: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function ModelPoolPanel({ draft, tests, busy, onChange, onTest }: ModelPoolPanelProps) {
|
function ModelPoolPanel({ draft, tests, busy, onChange, onTest, onTrial }: ModelPoolPanelProps) {
|
||||||
const pool = modelPoolFrom(draft);
|
const pool = modelPoolFrom(draft);
|
||||||
const [editor, setEditor] = useState<ModelPoolItem | null>(null);
|
const [editor, setEditor] = useState<ModelPoolItem | null>(null);
|
||||||
const [editorError, setEditorError] = useState("");
|
const [editorError, setEditorError] = useState("");
|
||||||
@@ -444,6 +495,16 @@ function ModelPoolPanel({ draft, tests, busy, onChange, onTest }: ModelPoolPanel
|
|||||||
onChange("model_pool", nextPool);
|
onChange("model_pool", nextPool);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyPreset(profile: ImageParameterProfile) {
|
||||||
|
if (!editor) return;
|
||||||
|
if (profile === "generic") {
|
||||||
|
setEditor({ ...editor, image_parameter_profile: "generic" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setEditor({ ...editor, ...imagePresets[profile] });
|
||||||
|
setShowAdvanced(false);
|
||||||
|
}
|
||||||
|
|
||||||
function saveEditor() {
|
function saveEditor() {
|
||||||
if (!editor) return;
|
if (!editor) return;
|
||||||
if (!editor.name.trim() || !editor.model_id.trim() || !editor.base_url.trim()) {
|
if (!editor.name.trim() || !editor.model_id.trim() || !editor.base_url.trim()) {
|
||||||
@@ -515,6 +576,18 @@ function ModelPoolPanel({ draft, tests, busy, onChange, onTest }: ModelPoolPanel
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="model-editor-grid">
|
<div className="model-editor-grid">
|
||||||
|
{editor.category === "multimodal" ? (
|
||||||
|
<label className="model-input model-input-wide">
|
||||||
|
<span>接入预设</span>
|
||||||
|
<select value={editor.image_parameter_profile} onChange={(event) => applyPreset(event.target.value as ImageParameterProfile)}>
|
||||||
|
<option value="generic">自定义模型 / 其他兼容接口</option>
|
||||||
|
<option value="gpt_image_2">GPT Image 2 · 聚合引擎 AIGC</option>
|
||||||
|
<option value="nano_banana_pro">Nano Banana Pro · 聚合引擎 AIGC</option>
|
||||||
|
<option value="seedream_5_pro">即梦 5.0 Pro · 聚合引擎 AIGC</option>
|
||||||
|
</select>
|
||||||
|
<small>选择预设会自动填写准确的模型 ID、协议、路径与参数格式。</small>
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
<label className="model-input">
|
<label className="model-input">
|
||||||
<span>模型分类</span>
|
<span>模型分类</span>
|
||||||
<select
|
<select
|
||||||
@@ -590,11 +663,19 @@ function ModelPoolPanel({ draft, tests, busy, onChange, onTest }: ModelPoolPanel
|
|||||||
</button>
|
</button>
|
||||||
{showAdvanced ? (
|
{showAdvanced ? (
|
||||||
<div className="model-editor-grid model-path-grid">
|
<div className="model-editor-grid model-path-grid">
|
||||||
|
<label className="model-input model-input-wide">
|
||||||
|
<span>图像调用协议</span>
|
||||||
|
<select value={editor.image_protocol} onChange={(event) => setEditor({ ...editor, image_protocol: event.target.value as ModelPoolItem["image_protocol"] })}>
|
||||||
|
<option value="openai_images">OpenAI Images 同步协议</option>
|
||||||
|
<option value="aigc_media">AIGC 媒体任务协议(创建后轮询)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
{([
|
{([
|
||||||
["models_path", "模型列表路径"],
|
["models_path", "模型列表路径"],
|
||||||
["chat_path", "对话路径"],
|
["chat_path", "对话路径"],
|
||||||
["image_generation_path", "生图路径"],
|
["image_generation_path", "生图路径"],
|
||||||
["image_edit_path", "图片编辑路径"],
|
["image_edit_path", "图片编辑路径"],
|
||||||
|
["image_status_path", "任务查询路径"],
|
||||||
] as const).map(([key, label]) => (
|
] as const).map(([key, label]) => (
|
||||||
<label className="model-input" key={key}>
|
<label className="model-input" key={key}>
|
||||||
<span>{label}</span>
|
<span>{label}</span>
|
||||||
@@ -623,6 +704,7 @@ function ModelPoolPanel({ draft, tests, busy, onChange, onTest }: ModelPoolPanel
|
|||||||
onEdit={(model) => { setEditor({ ...model, api_key: "" }); setShowAdvanced(false); }}
|
onEdit={(model) => { setEditor({ ...model, api_key: "" }); setShowAdvanced(false); }}
|
||||||
onRemove={removeModel}
|
onRemove={removeModel}
|
||||||
onTest={onTest}
|
onTest={onTest}
|
||||||
|
onTrial={onTrial}
|
||||||
/>
|
/>
|
||||||
<ModelGroup
|
<ModelGroup
|
||||||
title="多模态模型"
|
title="多模态模型"
|
||||||
@@ -635,6 +717,7 @@ function ModelPoolPanel({ draft, tests, busy, onChange, onTest }: ModelPoolPanel
|
|||||||
onEdit={(model) => { setEditor({ ...model, api_key: "" }); setShowAdvanced(false); }}
|
onEdit={(model) => { setEditor({ ...model, api_key: "" }); setShowAdvanced(false); }}
|
||||||
onRemove={removeModel}
|
onRemove={removeModel}
|
||||||
onTest={onTest}
|
onTest={onTest}
|
||||||
|
onTrial={onTrial}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<section className="model-routing">
|
<section className="model-routing">
|
||||||
@@ -690,9 +773,10 @@ type ModelGroupProps = {
|
|||||||
onEdit: (model: ModelPoolItem) => void;
|
onEdit: (model: ModelPoolItem) => void;
|
||||||
onRemove: (model: ModelPoolItem) => void;
|
onRemove: (model: ModelPoolItem) => void;
|
||||||
onTest: (modelId: string) => void;
|
onTest: (modelId: string) => void;
|
||||||
|
onTrial: (modelId: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
function ModelGroup({ title, description, category, models, tests, busy, onAdd, onEdit, onRemove, onTest }: ModelGroupProps) {
|
function ModelGroup({ title, description, category, models, tests, busy, onAdd, onEdit, onRemove, onTest, onTrial }: ModelGroupProps) {
|
||||||
return (
|
return (
|
||||||
<section className="model-group">
|
<section className="model-group">
|
||||||
<div className="model-group-heading">
|
<div className="model-group-heading">
|
||||||
@@ -713,6 +797,8 @@ function ModelGroup({ title, description, category, models, tests, busy, onAdd,
|
|||||||
{models.map((model) => {
|
{models.map((model) => {
|
||||||
const target = `model:${model.id}`;
|
const target = `model:${model.id}`;
|
||||||
const result = tests[target];
|
const result = tests[target];
|
||||||
|
const generationTarget = `generation:${model.id}`;
|
||||||
|
const generationResult = tests[generationTarget];
|
||||||
return (
|
return (
|
||||||
<article className={`model-row ${!model.enabled ? "disabled" : ""}`} key={model.id}>
|
<article className={`model-row ${!model.enabled ? "disabled" : ""}`} key={model.id}>
|
||||||
<div className={`model-state ${result?.ok ? "ok" : result ? "failed" : "untested"}`}>
|
<div className={`model-state ${result?.ok ? "ok" : result ? "failed" : "untested"}`}>
|
||||||
@@ -729,8 +815,14 @@ function ModelGroup({ title, description, category, models, tests, busy, onAdd,
|
|||||||
<div className="model-row-actions">
|
<div className="model-row-actions">
|
||||||
<button className="button button-secondary compact" type="button" onClick={() => onTest(model.id)} disabled={Boolean(busy) || !model.enabled}>
|
<button className="button button-secondary compact" type="button" onClick={() => onTest(model.id)} disabled={Boolean(busy) || !model.enabled}>
|
||||||
{busy === target ? <ArrowClockwiseIcon className="spin" size={14} /> : <PlugIcon size={14} />}
|
{busy === target ? <ArrowClockwiseIcon className="spin" size={14} /> : <PlugIcon size={14} />}
|
||||||
测试
|
验证模型
|
||||||
</button>
|
</button>
|
||||||
|
{model.capabilities.includes("image_generation") ? (
|
||||||
|
<button className="button button-secondary compact" type="button" onClick={() => onTrial(model.id)} disabled={Boolean(busy) || !model.enabled} title="会消耗一次模型调用额度">
|
||||||
|
{busy === generationTarget ? <ArrowClockwiseIcon className="spin" size={14} /> : <ImageIcon size={14} />}
|
||||||
|
试生成
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
<button className="icon-button small" type="button" onClick={() => onEdit(model)} disabled={Boolean(busy)} aria-label={`编辑${model.name}`}>
|
<button className="icon-button small" type="button" onClick={() => onEdit(model)} disabled={Boolean(busy)} aria-label={`编辑${model.name}`}>
|
||||||
<PencilSimpleIcon size={15} />
|
<PencilSimpleIcon size={15} />
|
||||||
</button>
|
</button>
|
||||||
@@ -738,7 +830,8 @@ function ModelGroup({ title, description, category, models, tests, busy, onAdd,
|
|||||||
<TrashIcon size={15} />
|
<TrashIcon size={15} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{result ? <p className={`model-test-message ${result.ok ? "ok" : "failed"}`}>{result.message}</p> : <p className="model-test-message">尚未测试这个模型。</p>}
|
{result ? <p className={`model-test-message ${result.ok ? "ok" : "failed"}`}>{result.message}</p> : <p className="model-test-message">尚未验证模型 ID。</p>}
|
||||||
|
{generationResult ? <p className={`model-test-message ${generationResult.ok ? "ok" : "failed"}`}>{generationResult.message}</p> : null}
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ export type TestResult = {
|
|||||||
|
|
||||||
export type ModelCategory = "language" | "multimodal";
|
export type ModelCategory = "language" | "multimodal";
|
||||||
export type ModelCapability = "orchestration" | "spatial_understanding" | "image_generation" | "image_editing";
|
export type ModelCapability = "orchestration" | "spatial_understanding" | "image_generation" | "image_editing";
|
||||||
|
export type ImageProtocol = "openai_images" | "aigc_media";
|
||||||
|
export type ImageParameterProfile = "generic" | "gpt_image_2" | "nano_banana_pro" | "seedream_5_pro";
|
||||||
|
|
||||||
export type ModelPoolItem = {
|
export type ModelPoolItem = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -66,6 +68,9 @@ export type ModelPoolItem = {
|
|||||||
chat_path: string;
|
chat_path: string;
|
||||||
image_generation_path: string;
|
image_generation_path: string;
|
||||||
image_edit_path: string;
|
image_edit_path: string;
|
||||||
|
image_status_path: string;
|
||||||
|
image_protocol: ImageProtocol;
|
||||||
|
image_parameter_profile: ImageParameterProfile;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
api_key?: string;
|
api_key?: string;
|
||||||
api_key_configured?: boolean;
|
api_key_configured?: boolean;
|
||||||
|
|||||||
+8
-3
@@ -33,9 +33,14 @@
|
|||||||
- 显示名称与平台模型 ID。
|
- 显示名称与平台模型 ID。
|
||||||
- API 来源、Base URL 和 API Key。
|
- API 来源、Base URL 和 API Key。
|
||||||
- 空间理解、图像生成、局部编辑等能力标签。
|
- 空间理解、图像生成、局部编辑等能力标签。
|
||||||
- 高级路径:`/models`、`/chat/completions`、`/images/generations`、`/images/edits`。
|
- 图像调用协议、模型专属参数预设及高级接口路径。
|
||||||
|
|
||||||
同一个聚合账号可以添加多个模型,也可以把不同平台的模型放进同一个池。每个模型都有单独的测试按钮。测试会访问该条目的模型列表接口,并明确显示是哪个模型 ID、凭证或接口路径出错,不再返回“部分模型不可用”这类无法定位的信息。
|
同一个聚合账号可以添加多个模型,也可以把不同平台的模型放进同一个池。聚合引擎 AIGC 已内置 GPT Image 2、Nano Banana Pro、即梦 5.0 Pro 三个接入预设,选择后会自动填写模型 ID、`/v1/media/generate`、`/v1/media/status` 及各模型不同的参数名。
|
||||||
|
|
||||||
|
每个模型提供两种测试:
|
||||||
|
|
||||||
|
- “验证模型”只访问模型列表并检查协议和路径,不生图、不扣费,用于定位模型 ID、凭证或配置错误。
|
||||||
|
- “试生成”会在用户确认后真实生成一张低成本测试图,会消耗一次调用额度,用来证明创建任务、轮询状态和取得结果的完整链路可用。
|
||||||
|
|
||||||
总调度模型必须由用户从大语言模型池中指定。空间理解和生图各自支持两种方式:
|
总调度模型必须由用户从大语言模型池中指定。空间理解和生图各自支持两种方式:
|
||||||
|
|
||||||
@@ -44,7 +49,7 @@
|
|||||||
|
|
||||||
旧版统一 API 配置在第一次读取时会自动迁移成三个模型池条目。出于安全考虑,迁移后的模型必须逐个重新测试。
|
旧版统一 API 配置在第一次读取时会自动迁移成三个模型池条目。出于安全考虑,迁移后的模型必须逐个重新测试。
|
||||||
|
|
||||||
聚合平台的 Base URL 不在代码中猜测或写死,仍需从登录后的开发者文档复制。
|
自定义平台的 Base URL 仍需从其开发者文档复制;三个内置聚合引擎预设固定使用文档提供的 `https://api.lk888.ai`。
|
||||||
|
|
||||||
## 为什么本地 GPU 仍然需要 Worker
|
## 为什么本地 GPU 仍然需要 Worker
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from app.runtime_settings import (
|
|||||||
SettingsReadiness,
|
SettingsReadiness,
|
||||||
generate_secret,
|
generate_secret,
|
||||||
get_runtime_store,
|
get_runtime_store,
|
||||||
|
test_image_generation,
|
||||||
test_model_pool_item,
|
test_model_pool_item,
|
||||||
test_runtime_settings,
|
test_runtime_settings,
|
||||||
)
|
)
|
||||||
@@ -79,6 +80,21 @@ async def test_model(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/settings/models/{model_id}/test-generation", response_model=RuntimeSettingsTestResult)
|
||||||
|
async def test_model_generation(
|
||||||
|
model_id: str,
|
||||||
|
store: EncryptedSettingsStore = Depends(get_runtime_store),
|
||||||
|
) -> RuntimeSettingsTestResult:
|
||||||
|
try:
|
||||||
|
return await test_image_generation(store.merged_values(), model_id)
|
||||||
|
except (RuntimeError, ValueError) as exc:
|
||||||
|
return RuntimeSettingsTestResult(
|
||||||
|
target=f"generation:{model_id}",
|
||||||
|
ok=False,
|
||||||
|
message=f"真实生图失败:{exc}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/settings/generate-secret", response_model=SecretGenerateResponse)
|
@router.post("/settings/generate-secret", response_model=SecretGenerateResponse)
|
||||||
def create_secret(request: SecretGenerateRequest) -> SecretGenerateResponse:
|
def create_secret(request: SecretGenerateRequest) -> SecretGenerateResponse:
|
||||||
return SecretGenerateResponse(value=generate_secret(request.kind))
|
return SecretGenerateResponse(value=generate_secret(request.kind))
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import urljoin
|
from urllib.parse import urljoin
|
||||||
@@ -63,6 +65,8 @@ class OpenAICompatibleGateway:
|
|||||||
**options: Any,
|
**options: Any,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
item = self.router.choose("image", model_instance_id)
|
item = self.router.choose("image", model_instance_id)
|
||||||
|
if item.get("image_protocol", "openai_images") == "aigc_media":
|
||||||
|
return await self._generate_aigc_media(item, prompt, options)
|
||||||
payload = {"model": item["model_id"], "prompt": prompt, **options}
|
payload = {"model": item["model_id"], "prompt": prompt, **options}
|
||||||
async with httpx.AsyncClient(timeout=180) as client:
|
async with httpx.AsyncClient(timeout=180) as client:
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
@@ -84,6 +88,14 @@ class OpenAICompatibleGateway:
|
|||||||
**options: Any,
|
**options: Any,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
item = self.router.choose("image", model_instance_id)
|
item = self.router.choose("image", model_instance_id)
|
||||||
|
if item.get("image_protocol", "openai_images") == "aigc_media":
|
||||||
|
encoded = base64.b64encode(image).decode("ascii")
|
||||||
|
references = [f"data:image/png;base64,{encoded}"]
|
||||||
|
if mask is not None:
|
||||||
|
mask_encoded = base64.b64encode(mask).decode("ascii")
|
||||||
|
references.append(f"data:image/png;base64,{mask_encoded}")
|
||||||
|
options["images"] = [*references, *list(options.pop("images", []))]
|
||||||
|
return await self._generate_aigc_media(item, prompt, options)
|
||||||
files: dict[str, tuple[str, bytes, str]] = {"image": (filename, image, "image/png")}
|
files: dict[str, tuple[str, bytes, str]] = {"image": (filename, image, "image/png")}
|
||||||
if mask is not None:
|
if mask is not None:
|
||||||
files["mask"] = ("mask.png", mask, "image/png")
|
files["mask"] = ("mask.png", mask, "image/png")
|
||||||
@@ -97,3 +109,79 @@ class OpenAICompatibleGateway:
|
|||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
return response.json()
|
return response.json()
|
||||||
|
|
||||||
|
async def _generate_aigc_media(
|
||||||
|
self,
|
||||||
|
item: Mapping[str, Any],
|
||||||
|
prompt: str,
|
||||||
|
options: Mapping[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
params, notify_url = self._media_params(item, options)
|
||||||
|
payload: dict[str, Any] = {"model": item["model_id"], "prompt": prompt, "params": params}
|
||||||
|
if notify_url:
|
||||||
|
payload["notify_url"] = notify_url
|
||||||
|
async with httpx.AsyncClient(timeout=180) as client:
|
||||||
|
response = await client.post(
|
||||||
|
self._url(item, "image_generation_path", "/v1/media/generate"),
|
||||||
|
headers=self._headers(item),
|
||||||
|
json=payload,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
created = response.json()
|
||||||
|
task_id = created.get("task_id") if isinstance(created, dict) else None
|
||||||
|
if not task_id:
|
||||||
|
return created
|
||||||
|
return await self._poll_aigc_media(client, item, task_id, created)
|
||||||
|
|
||||||
|
async def _poll_aigc_media(
|
||||||
|
self,
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
item: Mapping[str, Any],
|
||||||
|
task_id: str | int,
|
||||||
|
created: dict[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
for _ in range(45):
|
||||||
|
await asyncio.sleep(3)
|
||||||
|
response = await client.get(
|
||||||
|
self._url(item, "image_status_path", "/v1/media/status"),
|
||||||
|
headers=self._headers(item),
|
||||||
|
params={"task_id": task_id},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
status = response.json()
|
||||||
|
if not status.get("is_final"):
|
||||||
|
continue
|
||||||
|
if status.get("state") != "success":
|
||||||
|
reason = status.get("error") or status.get("status") or "平台返回失败状态"
|
||||||
|
raise RuntimeError(f"AIGC 任务 {task_id} 失败:{reason}")
|
||||||
|
result_url = status.get("result_url")
|
||||||
|
if result_url:
|
||||||
|
return {**created, **status, "data": [{"url": result_url}]}
|
||||||
|
return {**created, **status}
|
||||||
|
raise RuntimeError(f"AIGC 任务 {task_id} 在 135 秒内没有完成")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _media_params(
|
||||||
|
item: Mapping[str, Any],
|
||||||
|
options: Mapping[str, Any],
|
||||||
|
) -> tuple[dict[str, Any], str]:
|
||||||
|
raw = {key: value for key, value in options.items() if value is not None}
|
||||||
|
notify_url = str(raw.pop("notify_url", ""))
|
||||||
|
profile = item.get("image_parameter_profile", "generic")
|
||||||
|
if profile == "gpt_image_2":
|
||||||
|
allowed = {"images", "size", "quality"}
|
||||||
|
elif profile == "nano_banana_pro":
|
||||||
|
if "aspect_ratio" in raw and "aspectRatio" not in raw:
|
||||||
|
raw["aspectRatio"] = raw.pop("aspect_ratio")
|
||||||
|
if "size" in raw and "imageSize" not in raw:
|
||||||
|
raw["imageSize"] = raw.pop("size")
|
||||||
|
allowed = {"images", "aspectRatio", "imageSize"}
|
||||||
|
elif profile == "seedream_5_pro":
|
||||||
|
if "aspectRatio" in raw and "aspect_ratio" not in raw:
|
||||||
|
raw["aspect_ratio"] = raw.pop("aspectRatio")
|
||||||
|
if "imageSize" in raw and "size" not in raw:
|
||||||
|
raw["size"] = raw.pop("imageSize")
|
||||||
|
allowed = {"images", "aspect_ratio", "size"}
|
||||||
|
else:
|
||||||
|
allowed = set(raw)
|
||||||
|
return {key: value for key, value in raw.items() if key in allowed}, notify_url
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ class RuntimeSettingsTestResult(BaseModel):
|
|||||||
|
|
||||||
ModelCategory = Literal["language", "multimodal"]
|
ModelCategory = Literal["language", "multimodal"]
|
||||||
ModelCapability = Literal["orchestration", "spatial_understanding", "image_generation", "image_editing"]
|
ModelCapability = Literal["orchestration", "spatial_understanding", "image_generation", "image_editing"]
|
||||||
|
ImageProtocol = Literal["openai_images", "aigc_media"]
|
||||||
|
ImageParameterProfile = Literal["generic", "gpt_image_2", "nano_banana_pro", "seedream_5_pro"]
|
||||||
|
|
||||||
|
|
||||||
class ModelPoolItem(BaseModel):
|
class ModelPoolItem(BaseModel):
|
||||||
@@ -76,6 +78,9 @@ class ModelPoolItem(BaseModel):
|
|||||||
chat_path: str = "/chat/completions"
|
chat_path: str = "/chat/completions"
|
||||||
image_generation_path: str = "/images/generations"
|
image_generation_path: str = "/images/generations"
|
||||||
image_edit_path: str = "/images/edits"
|
image_edit_path: str = "/images/edits"
|
||||||
|
image_status_path: str = "/v1/media/status"
|
||||||
|
image_protocol: ImageProtocol = "openai_images"
|
||||||
|
image_parameter_profile: ImageParameterProfile = "generic"
|
||||||
enabled: bool = True
|
enabled: bool = True
|
||||||
|
|
||||||
|
|
||||||
@@ -233,6 +238,37 @@ class EncryptedSettingsStore:
|
|||||||
document["tests"].pop("ai_models", None)
|
document["tests"].pop("ai_models", None)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _migrate_model_protocol_fields(document: dict[str, Any]) -> bool:
|
||||||
|
changed = False
|
||||||
|
known_profiles = {
|
||||||
|
"gpt-image-2": "gpt_image_2",
|
||||||
|
"gemini-3-pro-image-preview": "nano_banana_pro",
|
||||||
|
"doubao-seedream-5-0-pro-260628": "seedream_5_pro",
|
||||||
|
}
|
||||||
|
for item in document.get("values", {}).get("model_pool", []):
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
defaults = {
|
||||||
|
"image_status_path": "/v1/media/status",
|
||||||
|
"image_protocol": "openai_images",
|
||||||
|
"image_parameter_profile": "generic",
|
||||||
|
}
|
||||||
|
host = urlparse(str(item.get("base_url", ""))).hostname or ""
|
||||||
|
profile = known_profiles.get(str(item.get("model_id", "")))
|
||||||
|
if host == "api.lk888.ai" and profile and "image_generation" in item.get("capabilities", []):
|
||||||
|
defaults.update(
|
||||||
|
image_protocol="aigc_media",
|
||||||
|
image_parameter_profile=profile,
|
||||||
|
image_generation_path="/v1/media/generate",
|
||||||
|
image_status_path="/v1/media/status",
|
||||||
|
)
|
||||||
|
for key, value in defaults.items():
|
||||||
|
if key not in item:
|
||||||
|
item[key] = value
|
||||||
|
changed = True
|
||||||
|
return changed
|
||||||
|
|
||||||
def load_document(self) -> dict[str, Any]:
|
def load_document(self) -> dict[str, Any]:
|
||||||
if not self.data_path.exists():
|
if not self.data_path.exists():
|
||||||
return {"values": self.defaults(), "tests": {}}
|
return {"values": self.defaults(), "tests": {}}
|
||||||
@@ -243,7 +279,9 @@ class EncryptedSettingsStore:
|
|||||||
raise RuntimeError("运行期配置无法解密,请检查主密钥是否发生变化。") from exc
|
raise RuntimeError("运行期配置无法解密,请检查主密钥是否发生变化。") from exc
|
||||||
document.setdefault("values", {})
|
document.setdefault("values", {})
|
||||||
document.setdefault("tests", {})
|
document.setdefault("tests", {})
|
||||||
if self._migrate_legacy_models(document):
|
migrated = self._migrate_legacy_models(document)
|
||||||
|
migrated = self._migrate_model_protocol_fields(document) or migrated
|
||||||
|
if migrated:
|
||||||
self.save_document(document)
|
self.save_document(document)
|
||||||
return document
|
return document
|
||||||
|
|
||||||
@@ -718,14 +756,116 @@ async def test_model_pool_item(values: dict[str, Any], model_id: str) -> Runtime
|
|||||||
message=f"模型“{name}”测试失败:账号模型列表中未找到 ID“{item['model_id']}”。",
|
message=f"模型“{name}”测试失败:账号模型列表中未找到 ID“{item['model_id']}”。",
|
||||||
details={"model_id": item["model_id"], "model_count": len(listed_ids)},
|
details={"model_id": item["model_id"], "model_count": len(listed_ids)},
|
||||||
)
|
)
|
||||||
|
protocol_error = _validate_image_protocol(item)
|
||||||
|
if protocol_error:
|
||||||
|
return RuntimeSettingsTestResult(
|
||||||
|
target=target,
|
||||||
|
ok=False,
|
||||||
|
message=f"模型“{name}”的 ID 可用,但生图协议配置不正确:{protocol_error}",
|
||||||
|
details={"model_id": item["model_id"], "model_count": len(listed_ids)},
|
||||||
|
)
|
||||||
return RuntimeSettingsTestResult(
|
return RuntimeSettingsTestResult(
|
||||||
target=target,
|
target=target,
|
||||||
ok=True,
|
ok=True,
|
||||||
message=f"模型“{name}”连接正常,模型 ID“{item['model_id']}”可用。",
|
message=f"模型“{name}”连接正常,模型 ID“{item['model_id']}”可用。此项不实际生图,不消耗额度。",
|
||||||
details={"model_id": item["model_id"], "model_count": len(listed_ids)},
|
details={"model_id": item["model_id"], "model_count": len(listed_ids)},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_image_protocol(item: dict[str, Any]) -> str:
|
||||||
|
if "image_generation" not in item.get("capabilities", []):
|
||||||
|
return ""
|
||||||
|
host = urlparse(str(item.get("base_url", ""))).hostname or ""
|
||||||
|
known_aigc_models = {
|
||||||
|
"gpt-image-2": "gpt_image_2",
|
||||||
|
"gemini-3-pro-image-preview": "nano_banana_pro",
|
||||||
|
"doubao-seedream-5-0-pro-260628": "seedream_5_pro",
|
||||||
|
}
|
||||||
|
expected_profile = known_aigc_models.get(str(item.get("model_id", "")))
|
||||||
|
if host == "api.lk888.ai" and expected_profile:
|
||||||
|
if item.get("image_protocol", "openai_images") != "aigc_media":
|
||||||
|
return "聚合引擎上的该模型应选择“AIGC 异步媒体协议”"
|
||||||
|
generation_path = urlparse(_join_api_url(str(item["base_url"]), str(item.get("image_generation_path", "")))).path
|
||||||
|
status_path = urlparse(_join_api_url(str(item["base_url"]), str(item.get("image_status_path", "")))).path
|
||||||
|
if generation_path != "/v1/media/generate":
|
||||||
|
return "生图地址应解析为 /v1/media/generate"
|
||||||
|
if status_path != "/v1/media/status":
|
||||||
|
return "任务查询地址应解析为 /v1/media/status"
|
||||||
|
if item.get("image_parameter_profile", "generic") != expected_profile:
|
||||||
|
return "参数预设与模型不匹配,请重新选择对应的模型预设"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
async def test_image_generation(values: dict[str, Any], model_id: str) -> RuntimeSettingsTestResult:
|
||||||
|
"""Run one real, billable image request to prove the configured protocol end to end."""
|
||||||
|
target = f"generation:{model_id}"
|
||||||
|
item = next(
|
||||||
|
(candidate for candidate in values.get("model_pool", []) if candidate.get("id") == model_id),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if item is None:
|
||||||
|
return RuntimeSettingsTestResult(target=target, ok=False, message="模型不存在或已被删除。")
|
||||||
|
if "image_generation" not in item.get("capabilities", []):
|
||||||
|
return RuntimeSettingsTestResult(target=target, ok=False, message="该模型没有启用图像生成能力。")
|
||||||
|
protocol_error = _validate_image_protocol(item)
|
||||||
|
if protocol_error:
|
||||||
|
return RuntimeSettingsTestResult(target=target, ok=False, message=f"无法试生成:{protocol_error}")
|
||||||
|
|
||||||
|
profile = item.get("image_parameter_profile", "generic")
|
||||||
|
trial_options: dict[str, Any] = {}
|
||||||
|
if profile == "gpt_image_2":
|
||||||
|
trial_options = {"size": "1024x1024", "quality": "low"}
|
||||||
|
elif profile == "nano_banana_pro":
|
||||||
|
trial_options = {"aspectRatio": "1:1", "imageSize": "1K"}
|
||||||
|
elif profile == "seedream_5_pro":
|
||||||
|
trial_options = {"aspect_ratio": "1:1", "size": "1K"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
from app.integrations.openai_compatible import OpenAICompatibleGateway
|
||||||
|
|
||||||
|
result = await OpenAICompatibleGateway(values).generate_image(
|
||||||
|
"极简室内材质测试图:一个米白色立方体放在浅灰背景中,无文字",
|
||||||
|
model_instance_id=model_id,
|
||||||
|
**trial_options,
|
||||||
|
)
|
||||||
|
output = _find_generated_image(result)
|
||||||
|
if not output:
|
||||||
|
return RuntimeSettingsTestResult(
|
||||||
|
target=target,
|
||||||
|
ok=False,
|
||||||
|
message="请求已结束,但响应中没有找到图片地址或图片数据。",
|
||||||
|
)
|
||||||
|
details = {"model_id": item["model_id"], "output_kind": output[0]}
|
||||||
|
if output[0] == "url":
|
||||||
|
details["result_url"] = output[1]
|
||||||
|
return RuntimeSettingsTestResult(
|
||||||
|
target=target,
|
||||||
|
ok=True,
|
||||||
|
message=f"模型“{item.get('name', item['model_id'])}”已完成一次真实生图,端到端配置可用。",
|
||||||
|
details=details,
|
||||||
|
)
|
||||||
|
except (httpx.HTTPError, RuntimeError, ValueError) as exc:
|
||||||
|
return RuntimeSettingsTestResult(target=target, ok=False, message=f"真实生图失败:{exc}")
|
||||||
|
|
||||||
|
|
||||||
|
def _find_generated_image(payload: dict[str, Any]) -> tuple[str, str] | None:
|
||||||
|
if payload.get("result_url"):
|
||||||
|
return "url", str(payload["result_url"])
|
||||||
|
for row in payload.get("data", []) if isinstance(payload.get("data"), list) else []:
|
||||||
|
if not isinstance(row, dict):
|
||||||
|
continue
|
||||||
|
if row.get("url"):
|
||||||
|
return "url", str(row["url"])
|
||||||
|
if row.get("b64_json"):
|
||||||
|
return "inline", ""
|
||||||
|
for candidate in payload.get("candidates", []) if isinstance(payload.get("candidates"), list) else []:
|
||||||
|
parts = candidate.get("content", {}).get("parts", []) if isinstance(candidate, dict) else []
|
||||||
|
for part in parts:
|
||||||
|
if isinstance(part, dict) and (part.get("inlineData", {}).get("data") or part.get("inline_data", {}).get("data")):
|
||||||
|
return "inline", ""
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def _test_gpu(values: dict[str, Any]) -> RuntimeSettingsTestResult:
|
async def _test_gpu(values: dict[str, Any]) -> RuntimeSettingsTestResult:
|
||||||
mode = values.get("gpu_mode", "disabled")
|
mode = values.get("gpu_mode", "disabled")
|
||||||
if mode == "disabled":
|
if mode == "disabled":
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.integrations.openai_compatible import OpenAICompatibleGateway
|
||||||
|
|
||||||
|
|
||||||
|
def image_values(profile: str, model_id: str) -> dict:
|
||||||
|
return {
|
||||||
|
"model_pool": [
|
||||||
|
{
|
||||||
|
"id": "image",
|
||||||
|
"name": "测试生图",
|
||||||
|
"model_id": model_id,
|
||||||
|
"category": "multimodal",
|
||||||
|
"provider": "lingke",
|
||||||
|
"base_url": "https://api.lk888.ai",
|
||||||
|
"api_key": "secret",
|
||||||
|
"capabilities": ["image_generation"],
|
||||||
|
"models_path": "/v1/models",
|
||||||
|
"image_protocol": "aigc_media",
|
||||||
|
"image_parameter_profile": profile,
|
||||||
|
"image_generation_path": "/v1/media/generate",
|
||||||
|
"image_status_path": "/v1/media/status",
|
||||||
|
"enabled": True,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"image_routing_mode": "manual",
|
||||||
|
"image_model_id": "image",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("profile", "model_id", "options", "expected_params"),
|
||||||
|
[
|
||||||
|
("gpt_image_2", "gpt-image-2", {"size": "1024x1024", "quality": "low"}, {"size": "1024x1024", "quality": "low"}),
|
||||||
|
("nano_banana_pro", "gemini-3-pro-image-preview", {"aspect_ratio": "1:1", "size": "1K"}, {"aspectRatio": "1:1", "imageSize": "1K"}),
|
||||||
|
("seedream_5_pro", "doubao-seedream-5-0-pro-260628", {"aspectRatio": "1:1", "imageSize": "1K"}, {"aspect_ratio": "1:1", "size": "1K"}),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_aigc_media_profiles_send_documented_parameter_names(
|
||||||
|
monkeypatch,
|
||||||
|
profile: str,
|
||||||
|
model_id: str,
|
||||||
|
options: dict,
|
||||||
|
expected_params: dict,
|
||||||
|
) -> None:
|
||||||
|
requests: list[dict] = []
|
||||||
|
|
||||||
|
class FakeResponse:
|
||||||
|
def raise_for_status(self) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def json(self) -> dict:
|
||||||
|
return {"data": [{"url": "https://cdn.example.com/test.png"}]}
|
||||||
|
|
||||||
|
class FakeClient:
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *args) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def post(self, url, **kwargs) -> FakeResponse:
|
||||||
|
requests.append({"url": url, **kwargs})
|
||||||
|
return FakeResponse()
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.integrations.openai_compatible.httpx.AsyncClient", lambda **kwargs: FakeClient())
|
||||||
|
|
||||||
|
result = await OpenAICompatibleGateway(image_values(profile, model_id)).generate_image("测试", **options)
|
||||||
|
|
||||||
|
assert result["data"][0]["url"].endswith("test.png")
|
||||||
|
assert requests[0]["url"] == "https://api.lk888.ai/v1/media/generate"
|
||||||
|
assert requests[0]["json"] == {"model": model_id, "prompt": "测试", "params": expected_params}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_aigc_media_polls_task_until_result_url(monkeypatch) -> None:
|
||||||
|
class FakeResponse:
|
||||||
|
def __init__(self, payload: dict) -> None:
|
||||||
|
self.payload = payload
|
||||||
|
|
||||||
|
def raise_for_status(self) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def json(self) -> dict:
|
||||||
|
return self.payload
|
||||||
|
|
||||||
|
class FakeClient:
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *args) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def post(self, *args, **kwargs) -> FakeResponse:
|
||||||
|
return FakeResponse({"task_id": 123})
|
||||||
|
|
||||||
|
async def get(self, url, **kwargs) -> FakeResponse:
|
||||||
|
assert url == "https://api.lk888.ai/v1/media/status"
|
||||||
|
assert kwargs["params"] == {"task_id": 123}
|
||||||
|
return FakeResponse({"task_id": 123, "state": "success", "is_final": True, "result_url": "https://cdn.example.com/final.png"})
|
||||||
|
|
||||||
|
async def no_sleep(*args) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.integrations.openai_compatible.httpx.AsyncClient", lambda **kwargs: FakeClient())
|
||||||
|
monkeypatch.setattr("app.integrations.openai_compatible.asyncio.sleep", no_sleep)
|
||||||
|
|
||||||
|
result = await OpenAICompatibleGateway(image_values("seedream_5_pro", "doubao-seedream-5-0-pro-260628")).generate_image("测试")
|
||||||
|
|
||||||
|
assert result["state"] == "success"
|
||||||
|
assert result["data"] == [{"url": "https://cdn.example.com/final.png"}]
|
||||||
Reference in New Issue
Block a user