Skip to content

Commit 130b8fa

Browse files
linshenkxclaude
andcommitted
feat(core): 新增 ModelScope(魔搭)API 集成支持
新增 ModelScope LLM 和图像生成服务的完整支持,包括: LLM 适配器特性: - 基于 OpenAI 兼容 API 实现,继承 OpenAIAdapter - 支持动态获取模型列表(models.list() 返回 62 个模型) - 内置 Qwen/Qwen3-Coder-480B-A35B-Instruct 静态模型 - 支持聊天和流式响应 - 每天免费 2000 次调用 图像生成适配器特性: - 实现异步任务提交和轮询机制 - 支持文生图(text-to-image)功能 - 内置 Z-Image-Turbo 模型 - 增强的错误处理和状态管理 - 使用 resolveEndpointUrl() 确保 URL 规范化 技术改进: - 正确处理自定义 baseURL 的 /v1 路径规范化 - 完善的轮询错误处理(SUCCEED/FAILED/ERROR/CANCELLED) - 错误响应体解析,提供详细错误信息 - 处理未知终态,避免超时等待 测试覆盖: - LLM 适配器单元测试(7 个测试用例) - 图像适配器单元测试(7 个测试用例,包括真实 API 测试) - 集成测试覆盖真实 API 调用和流式响应 配置集成: - 环境变量支持:VITE_MODELSCOPE_API_KEY - 自动注册到 LLM 和图像服务 registry - 默认配置自动生成和启用 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent 1f179c9 commit 130b8fa

11 files changed

Lines changed: 821 additions & 5 deletions

File tree

env.local.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@
2727
# SiliconFlow API 配置
2828
# VITE_SILICONFLOW_API_KEY=sk-your-siliconflow-api-key-here
2929

30+
# ModelScope (魔搭) API 配置 - 每天免费 2000 次调用
31+
# 支持文本模型(LLM)和图像生成模型
32+
# VITE_MODELSCOPE_API_KEY=your-modelscope-sdk-token-here
33+
3034
# 自定义 API 配置(如 Ollama 本地服务)
3135
# VITE_CUSTOM_API_KEY=your-custom-api-key
3236
# VITE_CUSTOM_API_BASE_URL=http://localhost:11434/v1

packages/core/src/services/image-model/defaults.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ const IMAGE_PROVIDER_ENV_KEYS = {
1212
openai: 'VITE_OPENAI_API_KEY',
1313
siliconflow: 'VITE_SILICONFLOW_API_KEY',
1414
seedream: 'VITE_SEEDREAM_API_KEY',
15-
dashscope: 'VITE_DASHSCOPE_API_KEY'
15+
dashscope: 'VITE_DASHSCOPE_API_KEY',
16+
modelscope: 'VITE_MODELSCOPE_API_KEY'
1617
} as const
1718

1819
/**
@@ -25,7 +26,8 @@ const IMAGE_CONFIG_IDS: Record<string, string> = {
2526
openai: 'image-openai-gpt',
2627
siliconflow: 'image-siliconflow-kolors',
2728
seedream: 'image-seedream',
28-
dashscope: 'image-dashscope'
29+
dashscope: 'image-dashscope',
30+
modelscope: 'image-modelscope'
2931
}
3032

3133
/**
Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
import { AbstractImageProviderAdapter } from './abstract-adapter'
2+
import type {
3+
ImageProvider,
4+
ImageModel,
5+
ImageRequest,
6+
ImageResult,
7+
ImageModelConfig,
8+
ImageParameterDefinition
9+
} from '../types'
10+
11+
/**
12+
* ModelScope (魔搭) 图像生成适配器
13+
*
14+
* API 端点: https://api-inference.modelscope.cn/v1/images/generations
15+
* 免费额度: 每天 2000 次调用
16+
* 文档: https://modelscope.cn/docs/model-service/API-Inference/intro
17+
*
18+
* 支持的模型:
19+
* - Tongyi-MAI/Z-Image-Turbo: 6B 参数高效图像生成模型(已验证可用)
20+
* - 其他模型请访问 ModelScope 文档查看当前支持列表
21+
* - 可以通过 buildDefaultModel() 创建任意模型 ID 的配置进行测试
22+
*
23+
* 环境变量支持:
24+
* - MODELSCOPE_API_KEY: SDK Token (Docker 环境,无 VITE_ 前缀)
25+
* - VITE_MODELSCOPE_API_KEY: SDK Token (开发环境,Vite 构建)
26+
*/
27+
export class ModelScopeImageAdapter extends AbstractImageProviderAdapter {
28+
protected normalizeBaseUrl(base: string): string {
29+
const trimmed = base.replace(/\/$/, '')
30+
// 确保 URL 以 /v1 结尾
31+
return /\/v1$/.test(trimmed) ? trimmed : `${trimmed}/v1`
32+
}
33+
34+
getProvider(): ImageProvider {
35+
return {
36+
id: 'modelscope',
37+
name: 'ModelScope',
38+
description: 'ModelScope 魔搭社区图像生成服务,每天免费 2000 次调用',
39+
requiresApiKey: true,
40+
defaultBaseURL: 'https://api-inference.modelscope.cn/v1',
41+
supportsDynamicModels: false,
42+
connectionSchema: {
43+
required: ['apiKey'],
44+
optional: ['baseURL'],
45+
fieldTypes: {
46+
apiKey: 'string',
47+
baseURL: 'string'
48+
}
49+
}
50+
}
51+
}
52+
53+
getModels(): ImageModel[] {
54+
return [
55+
{
56+
id: 'Tongyi-MAI/Z-Image-Turbo',
57+
name: 'Z-Image-Turbo',
58+
description: 'Z-Image-Turbo 6B 参数高效图像生成模型,擅长人像生成和快速出图(10步以内)',
59+
providerId: 'modelscope',
60+
capabilities: {
61+
text2image: true,
62+
image2image: false,
63+
multiImage: false
64+
},
65+
parameterDefinitions: this.getDefaultParameterDefinitions(),
66+
defaultParameterValues: {
67+
size: '1024x1024',
68+
n: 1
69+
}
70+
}
71+
]
72+
}
73+
74+
private getDefaultParameterDefinitions(): ImageParameterDefinition[] {
75+
return [
76+
{
77+
name: 'size',
78+
labelKey: 'image.params.size.label',
79+
descriptionKey: 'image.params.size.description',
80+
type: 'string',
81+
defaultValue: '1024x1024',
82+
allowedValues: ['1024x1024', '1536x1024', '1024x1536']
83+
},
84+
{
85+
name: 'n',
86+
labelKey: 'image.params.count.label',
87+
descriptionKey: 'image.params.count.description',
88+
type: 'integer',
89+
defaultValue: 1,
90+
minValue: 1,
91+
maxValue: 4
92+
}
93+
]
94+
}
95+
96+
protected getTestImageRequest(testType: 'text2image' | 'image2image'): Omit<ImageRequest, 'configId'> {
97+
if (testType === 'text2image') {
98+
return {
99+
prompt: '一朵简单的红色花朵',
100+
count: 1
101+
}
102+
}
103+
104+
throw new Error(`Test type ${testType} not supported by ModelScope image adapter`)
105+
}
106+
107+
protected getParameterDefinitions(_modelId: string): readonly ImageParameterDefinition[] {
108+
return this.getDefaultParameterDefinitions()
109+
}
110+
111+
protected getDefaultParameterValues(_modelId: string): Record<string, unknown> {
112+
return {
113+
size: '1024x1024',
114+
n: 1
115+
}
116+
}
117+
118+
protected async doGenerate(request: ImageRequest, config: ImageModelConfig): Promise<ImageResult> {
119+
// ModelScope 适配器仅支持文生图
120+
if (request.inputImage) {
121+
throw new Error('ModelScope adapter only supports text-to-image generation. For image editing, please use DashScope adapter.')
122+
}
123+
124+
return await this.generateImage(request, config)
125+
}
126+
127+
private async generateImage(request: ImageRequest, config: ImageModelConfig): Promise<ImageResult> {
128+
const url = this.resolveEndpointUrl(config, '/images/generations')
129+
130+
const merged: Record<string, any> = {
131+
...config.paramOverrides,
132+
...request.paramOverrides
133+
}
134+
135+
const payload = {
136+
model: config.modelId,
137+
prompt: request.prompt,
138+
size: merged.size || '1024x1024',
139+
n: merged.n || request.count || 1
140+
}
141+
142+
// 提交异步任务
143+
const response = await fetch(url, {
144+
method: 'POST',
145+
headers: {
146+
'Authorization': `Bearer ${config.connectionConfig?.apiKey}`,
147+
'Content-Type': 'application/json',
148+
'X-ModelScope-Async-Mode': 'true' // 异步模式
149+
},
150+
body: JSON.stringify(payload)
151+
})
152+
153+
if (!response.ok) {
154+
let errorMessage = `ModelScope API error: ${response.status} ${response.statusText}`
155+
try {
156+
const errorData = await response.json()
157+
if (errorData.message || errorData.error?.message) {
158+
errorMessage = errorData.message || errorData.error.message
159+
}
160+
} catch {
161+
// 忽略 JSON 解析错误
162+
}
163+
throw new Error(errorMessage)
164+
}
165+
166+
const submitData = await response.json()
167+
const taskId = submitData.task_id
168+
169+
if (!taskId) {
170+
throw new Error('No task_id received from ModelScope API')
171+
}
172+
173+
// 轮询任务状态
174+
return await this.pollTaskResult(taskId, config, 120, 3000)
175+
}
176+
177+
/**
178+
* 轮询任务结果
179+
*/
180+
private async pollTaskResult(
181+
taskId: string,
182+
config: ImageModelConfig,
183+
maxAttempts: number = 60,
184+
intervalMs: number = 2000
185+
): Promise<ImageResult> {
186+
const taskUrl = this.resolveEndpointUrl(config, `/tasks/${taskId}`)
187+
188+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
189+
await new Promise(resolve => setTimeout(resolve, intervalMs))
190+
191+
const response = await fetch(taskUrl, {
192+
method: 'GET',
193+
headers: {
194+
'Authorization': `Bearer ${config.connectionConfig?.apiKey}`,
195+
'X-ModelScope-Task-Type': 'image_generation'
196+
}
197+
})
198+
199+
if (!response.ok) {
200+
// 尝试解析错误响应体以提供更详细的错误信息
201+
let errorMessage = `${response.status} ${response.statusText}`
202+
try {
203+
const errorData = await response.json()
204+
if (errorData.error || errorData.message) {
205+
errorMessage = errorData.error || errorData.message
206+
}
207+
} catch {
208+
// 如果无法解析 JSON,使用默认错误信息
209+
}
210+
throw new Error(`Failed to poll task status: ${errorMessage}`)
211+
}
212+
213+
const data = await response.json()
214+
const status = data.task_status
215+
216+
if (status === 'SUCCEED') {
217+
// 任务成功,解析结果
218+
const outputImages = data.output_images || []
219+
if (outputImages.length === 0) {
220+
throw new Error('No output images in task result')
221+
}
222+
223+
const images = outputImages.map((imageUrl: string) => ({
224+
url: imageUrl,
225+
mimeType: 'image/png'
226+
}))
227+
228+
return {
229+
images,
230+
metadata: {
231+
providerId: 'modelscope',
232+
modelId: config.modelId,
233+
configId: config.id,
234+
taskId
235+
}
236+
}
237+
} else if (status === 'FAILED' || status === 'ERROR' || status === 'CANCELLED' || status === 'CANCELED') {
238+
// 任务失败或被取消,提取错误信息
239+
const errorMessage = data.error?.message || data.error || data.message || 'Unknown error'
240+
throw new Error(`Task ${status.toLowerCase()}: ${errorMessage}`)
241+
} else if (status !== 'PENDING' && status !== 'RUNNING') {
242+
// 未知的终态,视为失败
243+
throw new Error(`Unknown task status: ${status}`)
244+
}
245+
// task_status 为 PENDING 或 RUNNING,继续轮询
246+
}
247+
248+
throw new Error(`Task timeout after ${maxAttempts} attempts`)
249+
}
250+
251+
}

packages/core/src/services/image/adapters/registry.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { OpenAIImageAdapter } from './openai'
1111
import { SiliconFlowImageAdapter } from './siliconflow'
1212
import { OpenRouterImageAdapter } from './openrouter'
1313
import { DashScopeImageAdapter } from './dashscope'
14+
import { ModelScopeImageAdapter } from './modelscope'
1415

1516
/**
1617
* 图像适配器注册表实现
@@ -36,13 +37,15 @@ export class ImageAdapterRegistry
3637
const openaiAdapter = new OpenAIImageAdapter()
3738
const openrouterAdapter = new OpenRouterImageAdapter()
3839
const dashscopeAdapter = new DashScopeImageAdapter()
40+
const modelscopeAdapter = new ModelScopeImageAdapter()
3941

4042
this.adapters.set('gemini', geminiAdapter)
4143
this.adapters.set('seedream', seedreamAdapter)
4244
this.adapters.set('siliconflow', siliconflowAdapter)
4345
this.adapters.set('openai', openaiAdapter)
4446
this.adapters.set('openrouter', openrouterAdapter)
4547
this.adapters.set('dashscope', dashscopeAdapter)
48+
this.adapters.set('modelscope', modelscopeAdapter)
4649

4750
// 预加载静态模型缓存
4851
this.preloadStaticModels()
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import type { TextModel, TextProvider } from '../types'
2+
import { OpenAIAdapter } from './openai-adapter'
3+
4+
interface ModelOverride {
5+
id: string
6+
name: string
7+
description: string
8+
capabilities?: Partial<TextModel['capabilities']>
9+
defaultParameterValues?: Record<string, unknown>
10+
}
11+
12+
/**
13+
* ModelScope (魔搭) 静态模型定义
14+
* 参考: https://modelscope.cn/docs/model-service/API-Inference/intro
15+
*/
16+
const MODELSCOPE_STATIC_MODELS: ModelOverride[] = [
17+
{
18+
id: 'Qwen/Qwen3-Coder-480B-A35B-Instruct',
19+
name: 'Qwen3-Coder-480B-A35B-Instruct',
20+
description: '通义千问 Qwen/Qwen3-Coder-480B-A35B-Instruct,专为代码生成和理解优化',
21+
capabilities: {
22+
supportsTools: false, // 未验证 ModelScope 的工具调用兼容性
23+
supportsReasoning: false,
24+
maxContextLength: 131072
25+
}
26+
}
27+
]
28+
29+
/**
30+
* ModelScope (魔搭) 适配器
31+
* 基于 OpenAI 兼容 API 实现
32+
*
33+
* API 端点: https://api-inference.modelscope.cn/v1
34+
* 免费额度: 每天 2000 次调用
35+
* 文档: https://modelscope.cn/docs/model-service/API-Inference/intro
36+
*
37+
* 环境变量支持:
38+
* - MODELSCOPE_API_KEY: SDK Token (Docker 环境,无 VITE_ 前缀)
39+
* - VITE_MODELSCOPE_API_KEY: SDK Token (开发环境,Vite 构建)
40+
*/
41+
export class ModelScopeAdapter extends OpenAIAdapter {
42+
public getProvider(): TextProvider {
43+
return {
44+
id: 'modelscope',
45+
name: 'ModelScope',
46+
description: '阿里云魔搭社区 API 推理服务,每天免费 2000 次调用',
47+
requiresApiKey: true,
48+
defaultBaseURL: 'https://api-inference.modelscope.cn/v1',
49+
supportsDynamicModels: true,
50+
connectionSchema: {
51+
required: ['apiKey'],
52+
optional: ['baseURL'],
53+
fieldTypes: {
54+
apiKey: 'string',
55+
baseURL: 'string'
56+
}
57+
}
58+
}
59+
}
60+
61+
public getModels(): TextModel[] {
62+
return MODELSCOPE_STATIC_MODELS.map((definition) => {
63+
const baseModel = this.buildDefaultModel(definition.id)
64+
65+
return {
66+
...baseModel,
67+
name: definition.name,
68+
description: definition.description,
69+
capabilities: {
70+
...baseModel.capabilities,
71+
...(definition.capabilities ?? {})
72+
},
73+
defaultParameterValues: definition.defaultParameterValues
74+
? {
75+
...(baseModel.defaultParameterValues ?? {}),
76+
...definition.defaultParameterValues
77+
}
78+
: baseModel.defaultParameterValues
79+
}
80+
})
81+
}
82+
}

0 commit comments

Comments
 (0)