Skip to content

Commit 189b0cc

Browse files
committed
feat(api): add OpenAI-compatible /v1 images API
Adds /v1/images/generations + /v1/models and proxies HuggingFace Gradio URLs via /api/proxy-image.
1 parent d8b97d6 commit 189b0cc

8 files changed

Lines changed: 554 additions & 9 deletions

File tree

apps/api/src/app.ts

Lines changed: 48 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import {
4545
sendError,
4646
timeout,
4747
} from './middleware'
48+
import { registerOpenAIRoutes } from './openai/routes'
4849
import { getProvider, hasProvider } from './providers'
4950
import { createVideoTask, getVideoTaskStatus } from './providers/gitee'
5051
import { callGradioApi, formatDimensions, formatDuration } from './utils'
@@ -53,26 +54,31 @@ export interface AppConfig {
5354
corsOrigins?: string[]
5455
}
5556

56-
export function createApp(config: AppConfig = {}) {
57-
const app = new Hono().basePath('/api')
58-
59-
// Default CORS origins for development
60-
const defaultOrigins = ['http://localhost:5173', 'http://localhost:5174', 'http://localhost:3000']
61-
const origins = config.corsOrigins || defaultOrigins
62-
63-
// Pre-create CORS middleware instance (optimization)
64-
const corsMiddleware = cors({
57+
function createCorsMiddleware(origins: string[]) {
58+
return cors({
6559
origin: origins,
6660
allowMethods: ['GET', 'POST', 'OPTIONS'],
6761
allowHeaders: [
6862
'Content-Type',
63+
'Authorization',
6964
'X-API-Key',
7065
'X-HF-Token',
7166
'X-MS-Token',
7267
'X-DeepSeek-Token',
7368
'X-Request-ID',
7469
],
7570
})
71+
}
72+
73+
function createApiApp(config: AppConfig = {}) {
74+
const app = new Hono().basePath('/api')
75+
76+
// Default CORS origins for development
77+
const defaultOrigins = ['http://localhost:5173', 'http://localhost:5174', 'http://localhost:3000']
78+
const origins = config.corsOrigins || defaultOrigins
79+
80+
// Pre-create CORS middleware instance (optimization)
81+
const corsMiddleware = createCorsMiddleware(origins)
7682

7783
// Global error handlers
7884
app.onError(errorHandler)
@@ -767,6 +773,39 @@ export function createApp(config: AppConfig = {}) {
767773
return app
768774
}
769775

776+
function createOpenAIApp(config: AppConfig = {}) {
777+
const app = new Hono().basePath('/v1')
778+
779+
const defaultOrigins = ['http://localhost:5173', 'http://localhost:5174', 'http://localhost:3000']
780+
const origins = config.corsOrigins || defaultOrigins
781+
const corsMiddleware = createCorsMiddleware(origins)
782+
783+
app.onError(errorHandler)
784+
app.notFound(notFoundHandler)
785+
786+
app.use('/*', requestId)
787+
app.use('/*', corsMiddleware)
788+
app.use('/*', securityHeaders)
789+
app.use('/*', requestLogger)
790+
791+
app.use('/*', timeout(120000))
792+
app.use('/*', bodyLimit(50 * 1024))
793+
app.use('/*', rateLimitPresets.generate)
794+
795+
registerOpenAIRoutes(app)
796+
797+
return app
798+
}
799+
800+
export function createApp(config: AppConfig = {}) {
801+
const app = new Hono()
802+
app.onError(errorHandler)
803+
app.notFound(notFoundHandler)
804+
app.route('/', createOpenAIApp(config))
805+
app.route('/', createApiApp(config))
806+
return app
807+
}
808+
770809
// Default app instance for simple usage
771810
const app = createApp()
772811

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
/**
2+
* OpenAI-Compatible Routes Tests (/v1)
3+
* Tests with mocked fetch - no real API calls
4+
*/
5+
6+
import { ApiErrorCode } from '@z-image/shared'
7+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
8+
import { createApp } from '../../app'
9+
10+
describe('OpenAI-compatible routes', () => {
11+
const app = createApp()
12+
13+
beforeEach(() => {
14+
vi.stubGlobal('fetch', vi.fn())
15+
})
16+
17+
afterEach(() => {
18+
vi.unstubAllGlobals()
19+
})
20+
21+
it('POST /v1/images/generations defaults to HuggingFace z-image-turbo', async () => {
22+
const mockFetch = vi.mocked(fetch)
23+
24+
// Gradio queue
25+
mockFetch.mockResolvedValueOnce({
26+
ok: true,
27+
json: async () => ({ event_id: 'evt-1' }),
28+
} as Response)
29+
30+
// Gradio result (SSE)
31+
mockFetch.mockResolvedValueOnce({
32+
ok: true,
33+
text: async () => 'event: complete\ndata: [{"url":"https://hf.space/img.png"}, 42]\n\n',
34+
} as Response)
35+
36+
const res = await app.request('/v1/images/generations', {
37+
method: 'POST',
38+
headers: { 'Content-Type': 'application/json' },
39+
body: JSON.stringify({ prompt: 'a cat', size: '1024x1024' }),
40+
})
41+
42+
expect(res.status).toBe(200)
43+
const json = (await res.json()) as { created: number; data: Array<{ url: string }> }
44+
expect(json.created).toBeTypeOf('number')
45+
expect(json.data[0]?.url).toBe('https://hf.space/img.png')
46+
47+
expect(mockFetch.mock.calls[0]?.[0]).toContain('mrfakename-z-image-turbo.hf.space')
48+
})
49+
50+
it('POST /v1/images/generations supports gitee/ model prefix + gitee: token', async () => {
51+
const mockFetch = vi.mocked(fetch)
52+
mockFetch.mockResolvedValueOnce({
53+
ok: true,
54+
json: async () => ({ data: [{ url: 'https://example.com/gitee.png' }] }),
55+
} as Response)
56+
57+
const res = await app.request('/v1/images/generations', {
58+
method: 'POST',
59+
headers: {
60+
'Content-Type': 'application/json',
61+
Authorization: 'Bearer gitee:test-api-key',
62+
},
63+
body: JSON.stringify({
64+
model: 'gitee/z-image-turbo',
65+
prompt: 'a bird',
66+
size: '1024x768',
67+
negative_prompt: 'blurry',
68+
}),
69+
})
70+
71+
expect(res.status).toBe(200)
72+
const json = (await res.json()) as { data: Array<{ url: string }> }
73+
expect(json.data[0]?.url).toBe('https://example.com/gitee.png')
74+
75+
expect(mockFetch).toHaveBeenCalledWith(
76+
'https://ai.gitee.com/v1/images/generations',
77+
expect.objectContaining({
78+
method: 'POST',
79+
headers: expect.objectContaining({
80+
Authorization: 'Bearer test-api-key',
81+
}),
82+
})
83+
)
84+
85+
const body = JSON.parse((mockFetch.mock.calls[0]?.[1]?.body as string) || '{}')
86+
expect(body.width).toBe(1024)
87+
expect(body.height).toBe(768)
88+
expect(body.negative_prompt).toBe('blurry')
89+
})
90+
91+
it('POST /v1/images/generations supports ms/ model prefix + ms: token', async () => {
92+
const mockFetch = vi.mocked(fetch)
93+
94+
// submit
95+
mockFetch.mockResolvedValueOnce({
96+
ok: true,
97+
json: async () => ({ task_id: 'task-123' }),
98+
} as Response)
99+
100+
// poll
101+
mockFetch.mockResolvedValueOnce({
102+
ok: true,
103+
json: async () => ({ task_status: 'SUCCEED', output_images: ['https://example.com/ms.png'] }),
104+
} as Response)
105+
106+
const res = await app.request('/v1/images/generations', {
107+
method: 'POST',
108+
headers: {
109+
'Content-Type': 'application/json',
110+
Authorization: 'Bearer ms:token-12345678',
111+
},
112+
body: JSON.stringify({
113+
model: 'ms/flux-2',
114+
prompt: 'a robot',
115+
}),
116+
})
117+
118+
expect(res.status).toBe(200)
119+
const json = (await res.json()) as { data: Array<{ url: string }> }
120+
expect(json.data[0]?.url).toBe('https://example.com/ms.png')
121+
})
122+
123+
it('POST /v1/images/generations rejects n != 1', async () => {
124+
const res = await app.request('/v1/images/generations', {
125+
method: 'POST',
126+
headers: { 'Content-Type': 'application/json' },
127+
body: JSON.stringify({ prompt: 'a cat', n: 2 }),
128+
})
129+
130+
expect(res.status).toBe(400)
131+
const json = (await res.json()) as { code: string }
132+
expect(json.code).toBe(ApiErrorCode.INVALID_PARAMS)
133+
})
134+
135+
it('GET /v1/models returns OpenAI-like list', async () => {
136+
const res = await app.request('/v1/models')
137+
expect(res.status).toBe(200)
138+
139+
const json = (await res.json()) as { object: string; data: Array<{ id: string }> }
140+
expect(json.object).toBe('list')
141+
expect(json.data.map((m) => m.id)).toContain('z-image-turbo')
142+
expect(json.data.map((m) => m.id)).toContain('gitee/z-image-turbo')
143+
expect(json.data.map((m) => m.id)).toContain('ms/flux-2')
144+
})
145+
})
146+

apps/api/src/openai/adapter.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { Errors, type ProviderType, validateDimensions, validatePrompt, validateSteps } from '@z-image/shared'
2+
import type { ProviderGenerateRequest, ProviderGenerateResult } from '../providers/types'
3+
import type { OpenAIImageRequest, OpenAIImageResponse } from './types'
4+
5+
export type OpenAIConvertedRequest = Pick<
6+
ProviderGenerateRequest,
7+
'prompt' | 'negativePrompt' | 'width' | 'height' | 'steps' | 'seed' | 'guidanceScale'
8+
>
9+
10+
export function parseSize(size?: string): { width: number; height: number } {
11+
if (!size) return { width: 1024, height: 1024 }
12+
13+
const [w, h] = size.split('x').map((n) => Number.parseInt(n, 10))
14+
return {
15+
width: Number.isFinite(w) && w > 0 ? w : 1024,
16+
height: Number.isFinite(h) && h > 0 ? h : 1024,
17+
}
18+
}
19+
20+
export function parseBearerToken(authHeader?: string): { providerHint?: ProviderType; token?: string } {
21+
if (!authHeader) return {}
22+
if (!authHeader.startsWith('Bearer ')) return {}
23+
24+
const raw = authHeader.slice('Bearer '.length).trim()
25+
if (!raw) return {}
26+
27+
if (raw.startsWith('gitee:')) {
28+
const token = raw.slice('gitee:'.length).trim()
29+
return token ? { providerHint: 'gitee', token } : {}
30+
}
31+
if (raw.startsWith('ms:')) {
32+
const token = raw.slice('ms:'.length).trim()
33+
return token ? { providerHint: 'modelscope', token } : {}
34+
}
35+
36+
return { token: raw }
37+
}
38+
39+
export function convertRequest(req: OpenAIImageRequest): OpenAIConvertedRequest {
40+
const promptValidation = validatePrompt(req.prompt)
41+
if (!promptValidation.valid) {
42+
throw Errors.invalidPrompt(promptValidation.error || 'Invalid prompt')
43+
}
44+
45+
const { width, height } = parseSize(req.size)
46+
const dimensionsValidation = validateDimensions(width, height)
47+
if (!dimensionsValidation.valid) {
48+
throw Errors.invalidDimensions(dimensionsValidation.error || 'Invalid dimensions')
49+
}
50+
51+
const steps = req.steps ?? (req.quality === 'hd' ? 30 : undefined)
52+
if (steps !== undefined) {
53+
const stepsValidation = validateSteps(steps)
54+
if (!stepsValidation.valid) {
55+
throw Errors.invalidParams('steps', stepsValidation.error || 'Invalid steps')
56+
}
57+
}
58+
59+
return {
60+
prompt: req.prompt,
61+
negativePrompt: req.negative_prompt,
62+
width,
63+
height,
64+
steps,
65+
seed: req.seed,
66+
guidanceScale: req.guidance_scale,
67+
}
68+
}
69+
70+
export function convertResponse(result: ProviderGenerateResult): OpenAIImageResponse {
71+
return {
72+
created: Math.floor(Date.now() / 1000),
73+
data: [{ url: result.url }],
74+
}
75+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import type { ProviderType } from '@z-image/shared'
2+
3+
interface ResolvedModel {
4+
provider: ProviderType
5+
model: string
6+
}
7+
8+
const DEFAULT_HF_MODEL = 'z-image-turbo'
9+
const HF_MODELS = new Set(['z-image-turbo', 'qwen-image-fast', 'ovis-image', 'flux-1-schnell'])
10+
11+
const GITEE_MODEL_ALIASES: Record<string, string> = {
12+
'z-image-turbo': 'z-image-turbo',
13+
'qwen-image': 'Qwen-Image',
14+
'flux-1-krea-dev': 'FLUX_1-Krea-dev',
15+
'flux-1-dev': 'FLUX.1-dev',
16+
}
17+
18+
const MODELSCOPE_MODEL_ALIASES: Record<string, string> = {
19+
'z-image-turbo': 'Tongyi-MAI/Z-Image-Turbo',
20+
'flux-2': 'black-forest-labs/FLUX.2-dev',
21+
'flux-1-krea-dev': 'black-forest-labs/FLUX.1-Krea-dev',
22+
'flux-1': 'MusePublic/489_ckpt_FLUX_1',
23+
}
24+
25+
export function resolveModel(modelParam?: string): ResolvedModel {
26+
const model = (modelParam || DEFAULT_HF_MODEL).trim()
27+
28+
if (model.startsWith('gitee/')) {
29+
const raw = model.slice('gitee/'.length)
30+
return { provider: 'gitee', model: GITEE_MODEL_ALIASES[raw] || raw }
31+
}
32+
33+
if (model.startsWith('ms/')) {
34+
const raw = model.slice('ms/'.length)
35+
return { provider: 'modelscope', model: MODELSCOPE_MODEL_ALIASES[raw] || raw }
36+
}
37+
38+
return { provider: 'huggingface', model: HF_MODELS.has(model) ? model : DEFAULT_HF_MODEL }
39+
}

0 commit comments

Comments
 (0)