Fix finalize provider routing

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
bulai0408
2026-05-17 20:38:02 +08:00
co-authored by multica-agent
parent e93583c550
commit 6c334e08d1
9 changed files with 519 additions and 88 deletions
+325 -56
View File
@@ -3,20 +3,18 @@
// (via `exportProjectTranscript` from PR #493), the project's active design
// system body, and the project's "current artifact" (active artifact tab,
// fallback to newest .artifact.json by manifest.updatedAt, fallback null),
// runs them through Claude's Messages API, and writes the synthesized
// Markdown back to disk atomically.
// runs them through the user's selected BYOK provider, and writes the
// synthesized Markdown back to disk atomically.
//
// Per-project lockfile semantics (`.finalize.lock`) mirror PR #493's
// transcript-export hygiene. A second concurrent finalize throws
// `FinalizePackageLockedError`. Stale-lock recovery (e.g. after a crash)
// is out of scope; operators clear via `rm <projectDir>/.finalize.lock`.
//
// API key, base URL, and model flow in via the route's request body
// (matching the proxy at `apps/daemon/src/server.ts`'s
// `/api/proxy/anthropic/stream`). The daemon does NOT store provider
// credentials. `baseUrl` is optional here (intentional divergence from
// the proxy, which requires it) so standard Anthropic users don't need
// to set it; Bedrock / self-hosted-proxy users still can.
// Provider protocol, API key, base URL, and model flow in via the route's
// request body. The daemon does NOT store provider credentials. `baseUrl`
// is optional when the selected provider has a daemon default; custom
// gateways still pass it explicitly.
//
// Inline `PersistedAgentEvent` shape is restated in this file (the daemon
// tsconfig does not resolve the `@open-design/contracts/api/chat` subpath
@@ -31,6 +29,7 @@ import type {
FinalizeAnthropicRequest,
FinalizeAnthropicResponse,
FinalizeArtifactRef,
FinalizeProviderProtocol,
} from '@open-design/contracts/api/finalize';
import { getProject } from './db.js';
import { readDesignSystem } from './design-systems.js';
@@ -51,20 +50,44 @@ export type {
FinalizeAnthropicRequest,
FinalizeAnthropicResponse,
FinalizeArtifactRef,
FinalizeProviderProtocol,
};
const DEFAULT_BASE_URL = 'https://api.anthropic.com';
const DEFAULT_BASE_URL_BY_PROTOCOL: Record<FinalizeProviderProtocol, string> = {
anthropic: 'https://api.anthropic.com',
openai: 'https://api.openai.com',
azure: '',
google: 'https://generativelanguage.googleapis.com',
ollama: 'https://ollama.com',
};
const DEFAULT_MAX_TOKENS = 16000;
const INPUT_BODY_CAP_BYTES = 384 * 1024;
const LOCK_FILENAME = '.finalize.lock';
const OUTPUT_FILENAME = 'DESIGN.md';
const DEFAULT_TIMEOUT_MS = 120_000;
const FINALIZE_PROVIDER_PROTOCOLS = new Set<FinalizeProviderProtocol>([
'anthropic',
'openai',
'azure',
'google',
'ollama',
]);
export function isFinalizeProviderProtocol(value: unknown): value is FinalizeProviderProtocol {
return typeof value === 'string' && FINALIZE_PROVIDER_PROTOCOLS.has(value as FinalizeProviderProtocol);
}
export function defaultBaseUrlForFinalizeProtocol(protocol: FinalizeProviderProtocol): string {
return DEFAULT_BASE_URL_BY_PROTOCOL[protocol] ?? '';
}
export interface FinalizeOptions {
protocol?: FinalizeProviderProtocol;
apiKey: string;
baseUrl?: string;
model: string;
maxTokens?: number;
apiVersion?: string;
now?: () => Date;
fetchImpl?: typeof globalThis.fetch;
signal?: AbortSignal;
@@ -245,7 +268,8 @@ export async function finalizeDesignPackage(
`${OUTPUT_FILENAME}.tmp.${process.pid}.${randomBytes(4).toString('hex')}`,
);
const now = options.now ?? (() => new Date());
const baseUrl = options.baseUrl ?? DEFAULT_BASE_URL;
const protocol = options.protocol ?? 'anthropic';
const baseUrl = options.baseUrl ?? defaultBaseUrlForFinalizeProtocol(protocol);
const maxTokens = options.maxTokens ?? DEFAULT_MAX_TOKENS;
let lockFd: number | null = null;
@@ -318,7 +342,8 @@ export async function finalizeDesignPackage(
const timeoutId = setTimeout(() => timeoutController.abort(), timeoutMs);
let response: Response;
try {
const callParams: AnthropicCallParams = {
const callParams: FinalizeProviderCallParams = {
protocol,
apiKey: options.apiKey,
baseUrl,
model: options.model,
@@ -326,12 +351,13 @@ export async function finalizeDesignPackage(
systemPrompt,
userPrompt,
};
if (options.apiVersion) callParams.apiVersion = options.apiVersion;
callParams.signal = options.signal
? AbortSignal.any([options.signal, timeoutController.signal])
: timeoutController.signal;
if (options.fetchImpl) callParams.fetchImpl = options.fetchImpl;
try {
response = await callAnthropicWithRetry(callParams);
response = await callFinalizeProviderWithRetry(callParams);
} catch (err: unknown) {
if (err instanceof FinalizeUpstreamError) throw err;
const errName =
@@ -343,7 +369,7 @@ export async function finalizeDesignPackage(
// via cause.code, etc.) — rewrap as upstream failure so the route
// handler maps to 502 UPSTREAM_FAILED with redacted details.
const message = err instanceof Error ? err.message : String(err);
throw new FinalizeUpstreamError(502, '', `upstream network error: ${message}`);
throw new FinalizeUpstreamError(502, '', `upstream ${protocol} network error: ${message}`);
}
} finally {
clearTimeout(timeoutId);
@@ -360,13 +386,11 @@ export async function finalizeDesignPackage(
throw new FinalizeUpstreamError(
502,
'',
`upstream Anthropic returned non-JSON body: ${message}`,
`upstream ${providerLabel(protocol)} returned non-JSON body: ${message}`,
);
}
const designMd = extractDesignMd(payload);
const usage = (payload as { usage?: { input_tokens?: number; output_tokens?: number } }).usage ?? {};
const inputTokens = typeof usage.input_tokens === 'number' ? usage.input_tokens : 0;
const outputTokens = typeof usage.output_tokens === 'number' ? usage.output_tokens : 0;
const designMd = extractDesignMd(payload, protocol);
const { inputTokens, outputTokens } = extractTokenUsage(payload, protocol);
// Phase 9: atomic write. Mirror PR #493: writeFileSync({flag:'wx'}) →
// reopen for fsync → rename. On any failure unlink tmp; rethrow so the
@@ -436,23 +460,168 @@ export function appendVersionedApiPath(baseUrl: string, suffix: string): string
return url.toString();
}
export interface AnthropicCallParams {
export interface FinalizeProviderCallParams {
protocol: FinalizeProviderProtocol;
apiKey: string;
baseUrl: string;
model: string;
maxTokens: number;
systemPrompt: string;
userPrompt: string;
apiVersion?: string;
signal?: AbortSignal;
fetchImpl?: typeof globalThis.fetch;
/** Test-only: skip the inter-attempt sleep so retries are instant. */
_sleepMs?: (ms: number) => Promise<void>;
}
export type AnthropicCallParams = Omit<FinalizeProviderCallParams, 'protocol' | 'apiVersion'>;
const defaultSleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
interface FinalizeProviderHttpRequest {
url: string;
headers: Record<string, string>;
body: unknown;
}
function buildAzureChatCompletionsUrl(baseUrl: string, model: string, apiVersion?: string): {
url: string;
usesVersionedOpenAIPath: boolean;
} {
const url = new URL(baseUrl);
const basePath = url.pathname.replace(/\/+$/, '');
const usesVersionedOpenAIPath = /\/openai\/v\d+(?:$|\/)/.test(basePath);
const version =
apiVersion && apiVersion.trim()
? apiVersion.trim()
: usesVersionedOpenAIPath
? ''
: '2024-10-21';
url.pathname = usesVersionedOpenAIPath
? `${basePath}/chat/completions`
: `${basePath}/openai/deployments/${encodeURIComponent(model)}/chat/completions`;
if (usesVersionedOpenAIPath && !version) {
url.searchParams.delete('api-version');
}
if (version) {
url.searchParams.set('api-version', version);
}
return { url: url.toString(), usesVersionedOpenAIPath };
}
function buildFinalizeProviderRequest(params: FinalizeProviderCallParams): FinalizeProviderHttpRequest {
const payloadMessages = [
{ role: 'system', content: params.systemPrompt },
{ role: 'user', content: params.userPrompt },
];
if (params.protocol === 'anthropic') {
return {
url: appendVersionedApiPath(params.baseUrl, '/messages'),
headers: {
'content-type': 'application/json',
'x-api-key': params.apiKey,
'anthropic-version': '2023-06-01',
},
body: {
model: params.model,
max_tokens: params.maxTokens,
system: params.systemPrompt,
messages: [{ role: 'user', content: params.userPrompt }],
stream: false,
},
};
}
if (params.protocol === 'openai') {
return {
url: appendVersionedApiPath(params.baseUrl, '/chat/completions'),
headers: {
'content-type': 'application/json',
authorization: `Bearer ${params.apiKey}`,
},
body: {
model: params.model,
messages: payloadMessages,
max_tokens: params.maxTokens,
stream: false,
},
};
}
if (params.protocol === 'azure') {
const { url, usesVersionedOpenAIPath } = buildAzureChatCompletionsUrl(
params.baseUrl,
params.model,
params.apiVersion,
);
return {
url,
headers: {
'content-type': 'application/json',
'api-key': params.apiKey,
},
body: {
...(usesVersionedOpenAIPath ? { model: params.model } : {}),
messages: payloadMessages,
max_tokens: params.maxTokens,
stream: false,
},
};
}
if (params.protocol === 'google') {
const clean = params.baseUrl.replace(/\/+$/, '');
return {
url: `${clean}/v1beta/models/${encodeURIComponent(params.model)}:generateContent`,
headers: {
'content-type': 'application/json',
'x-goog-api-key': params.apiKey,
},
body: {
contents: [
{
role: 'user',
parts: [{ text: params.userPrompt }],
},
],
systemInstruction: {
parts: [{ text: params.systemPrompt }],
},
generationConfig: {
maxOutputTokens: params.maxTokens,
},
},
};
}
const clean = params.baseUrl.replace(/\/+$/, '').replace(/\/api\/?$/, '');
return {
url: `${clean}/api/chat`,
headers: {
'content-type': 'application/json',
authorization: `Bearer ${params.apiKey}`,
},
body: {
model: params.model,
messages: payloadMessages,
stream: false,
options: { num_predict: params.maxTokens },
},
};
}
function providerLabel(protocol: FinalizeProviderProtocol): string {
if (protocol === 'google') return 'Google Gemini';
if (protocol === 'openai') return 'OpenAI';
if (protocol === 'azure') return 'Azure OpenAI';
if (protocol === 'ollama') return 'Ollama';
return 'Anthropic';
}
/**
* Call Anthropic's Messages API once, retrying once on a transient
* Call the selected provider API once, retrying once on a transient
* upstream failure (HTTP 429 or 5xx). On a terminal failure, throw a
* `FinalizeUpstreamError` carrying the upstream HTTP status and raw
* body text — the route handler maps the status to one of
@@ -465,35 +634,33 @@ const defaultSleep = (ms: number): Promise<void> => new Promise((r) => setTimeou
* retry matches the existing daemon's posture (transcript export and
* connectionTest do zero retries).
*/
export async function callAnthropicWithRetry(
params: AnthropicCallParams,
export async function callFinalizeProviderWithRetry(
params: FinalizeProviderCallParams,
): Promise<Response> {
const fetchImpl = params.fetchImpl ?? globalThis.fetch;
const sleep = params._sleepMs ?? defaultSleep;
const url = appendVersionedApiPath(params.baseUrl, '/messages');
const headers: Record<string, string> = {
'content-type': 'application/json',
'x-api-key': params.apiKey,
'anthropic-version': '2023-06-01',
};
const body = JSON.stringify({
model: params.model,
max_tokens: params.maxTokens,
system: params.systemPrompt,
messages: [{ role: 'user', content: params.userPrompt }],
stream: false,
});
const request = buildFinalizeProviderRequest(params);
const body = JSON.stringify(request.body);
for (let attempt = 0; attempt <= 1; attempt += 1) {
const init: RequestInit = { method: 'POST', headers, body };
const init: RequestInit = {
method: 'POST',
headers: request.headers,
body,
redirect: 'error',
};
if (params.signal) init.signal = params.signal;
const response = await fetchImpl(url, init);
const response = await fetchImpl(request.url, init);
if (response.ok) return response;
const transient = response.status === 429 || response.status >= 500;
if (!transient || attempt === 1) {
const text = await response.text().catch(() => '');
throw new FinalizeUpstreamError(response.status, text);
throw new FinalizeUpstreamError(
response.status,
text,
`upstream ${providerLabel(params.protocol)} returned ${response.status}`,
);
}
// Linear backoff: 1s on attempt 0. Two retries would extend to 2s on
// attempt 1 — kept at one retry to stay within the daemon's blocking-
@@ -502,45 +669,147 @@ export async function callAnthropicWithRetry(
}
// Loop above always returns or throws within two iterations. This is
// unreachable; satisfies TypeScript control-flow analysis.
throw new Error('callAnthropicWithRetry: unreachable');
throw new Error('callFinalizeProviderWithRetry: unreachable');
}
export async function callAnthropicWithRetry(
params: AnthropicCallParams,
): Promise<Response> {
return callFinalizeProviderWithRetry({ ...params, protocol: 'anthropic' });
}
/**
* Extract the Markdown body from Anthropic's Messages API response.
* Concatenates `content[].text` for every block where `type === 'text'`,
* preserving order. Throws `FinalizeUpstreamError(502)` if the response
* shape is unexpected (no content array, no text blocks) — synthesis
* cannot proceed, and the route handler maps the throw to
* `502 UPSTREAM_FAILED` rather than producing an empty DESIGN.md on disk.
* Extract the Markdown body from a provider response. Anthropic returns
* `content[].text`; OpenAI/Azure return `choices[].message.content`;
* Gemini returns `candidates[].content.parts[].text`; Ollama returns
* `message.content`. Unexpected shapes throw `FinalizeUpstreamError(502)`
* so the route handler maps them to `502 UPSTREAM_FAILED` rather than
* producing an empty DESIGN.md on disk.
*/
export function extractDesignMd(payload: unknown): string {
export function extractDesignMd(
payload: unknown,
protocol: FinalizeProviderProtocol = 'anthropic',
): string {
if (!payload || typeof payload !== 'object') {
throw new FinalizeUpstreamError(502, '', 'upstream Anthropic response was not an object');
}
const content = (payload as { content?: unknown }).content;
if (!Array.isArray(content)) {
throw new FinalizeUpstreamError(
502,
'',
'upstream Anthropic response had no content array',
`upstream ${providerLabel(protocol)} response was not an object`,
);
}
let out = '';
for (const block of content) {
if (!block || typeof block !== 'object') continue;
const b = block as { type?: unknown; text?: unknown };
if (b.type === 'text' && typeof b.text === 'string') out += b.text;
if (protocol === 'anthropic') {
const content = (payload as { content?: unknown }).content;
if (!Array.isArray(content)) {
throw new FinalizeUpstreamError(
502,
'',
'upstream Anthropic response had no content array',
);
}
for (const block of content) {
if (!block || typeof block !== 'object') continue;
const b = block as { type?: unknown; text?: unknown };
if (b.type === 'text' && typeof b.text === 'string') out += b.text;
}
} else if (protocol === 'openai' || protocol === 'azure') {
const choices = (payload as { choices?: unknown }).choices;
if (!Array.isArray(choices)) {
throw new FinalizeUpstreamError(
502,
'',
`upstream ${providerLabel(protocol)} response had no choices array`,
);
}
for (const choice of choices) {
if (!choice || typeof choice !== 'object') continue;
const message = (choice as { message?: { content?: unknown }; text?: unknown }).message;
if (typeof message?.content === 'string') out += message.content;
if (typeof (choice as { text?: unknown }).text === 'string') {
out += (choice as { text: string }).text;
}
}
} else if (protocol === 'google') {
const candidates = (payload as { candidates?: unknown }).candidates;
if (!Array.isArray(candidates)) {
throw new FinalizeUpstreamError(
502,
'',
'upstream Google Gemini response had no candidates array',
);
}
for (const candidate of candidates) {
const parts = (candidate as { content?: { parts?: unknown } } | null)?.content?.parts;
if (!Array.isArray(parts)) continue;
for (const part of parts) {
if (part && typeof part === 'object' && typeof (part as { text?: unknown }).text === 'string') {
out += (part as { text: string }).text;
}
}
}
} else {
const message = (payload as { message?: { content?: unknown } }).message;
if (typeof message?.content === 'string') out += message.content;
}
if (out.length === 0) {
throw new FinalizeUpstreamError(
502,
'',
'upstream Anthropic response contained no text blocks',
`upstream ${providerLabel(protocol)} response contained no text`,
);
}
return out;
}
function extractTokenUsage(
payload: unknown,
protocol: FinalizeProviderProtocol,
): { inputTokens: number; outputTokens: number } {
if (!payload || typeof payload !== 'object') {
return { inputTokens: 0, outputTokens: 0 };
}
if (protocol === 'anthropic') {
const usage = (payload as { usage?: { input_tokens?: unknown; output_tokens?: unknown } }).usage;
return {
inputTokens: typeof usage?.input_tokens === 'number' ? usage.input_tokens : 0,
outputTokens: typeof usage?.output_tokens === 'number' ? usage.output_tokens : 0,
};
}
if (protocol === 'openai' || protocol === 'azure') {
const usage = (payload as { usage?: { prompt_tokens?: unknown; completion_tokens?: unknown } }).usage;
return {
inputTokens: typeof usage?.prompt_tokens === 'number' ? usage.prompt_tokens : 0,
outputTokens: typeof usage?.completion_tokens === 'number' ? usage.completion_tokens : 0,
};
}
if (protocol === 'google') {
const usage = (payload as {
usageMetadata?: { promptTokenCount?: unknown; candidatesTokenCount?: unknown };
}).usageMetadata;
return {
inputTokens: typeof usage?.promptTokenCount === 'number' ? usage.promptTokenCount : 0,
outputTokens: typeof usage?.candidatesTokenCount === 'number' ? usage.candidatesTokenCount : 0,
};
}
return {
inputTokens:
typeof (payload as { prompt_eval_count?: unknown }).prompt_eval_count === 'number'
? (payload as { prompt_eval_count: number }).prompt_eval_count
: 0,
outputTokens:
typeof (payload as { eval_count?: unknown }).eval_count === 'number'
? (payload as { eval_count: number }).eval_count
: 0,
};
}
const SYSTEM_PROMPT = `You are a senior product designer synthesizing a finalized design package
from a multi-turn design session. Your output is a single Markdown document
named DESIGN.md that captures the durable design intent of the work so a
+54 -16
View File
@@ -534,9 +534,16 @@ export function registerFinalizeRoutes(app: Express, ctx: RegisterFinalizeRoutes
const { PROJECTS_DIR, DESIGN_SYSTEMS_DIR } = ctx.paths;
const { getProject } = ctx.projectStore;
const { isSafeId, validateExternalApiBaseUrl } = ctx.validation;
const { finalizeDesignPackage, FinalizePackageLockedError, FinalizeUpstreamError, redactSecrets } = ctx.finalize;
app.post('/api/projects/:id/finalize/anthropic', async (req, res) => {
const { apiKey, baseUrl, model, maxTokens } = req.body || {};
const {
defaultBaseUrlForFinalizeProtocol,
finalizeDesignPackage,
FinalizePackageLockedError,
FinalizeUpstreamError,
isFinalizeProviderProtocol,
redactSecrets,
} = ctx.finalize;
app.post('/api/projects/:id/finalize/:provider', async (req, res) => {
const { apiKey, baseUrl, model, maxTokens, apiVersion, protocol: bodyProtocol } = req.body || {};
try {
// Centralized path-traversal guard. `isSafeId` (apps/daemon/src/projects.ts)
// rejects pure-dot ids (`.`, `..`, etc.) which would otherwise pass
@@ -548,29 +555,50 @@ export function registerFinalizeRoutes(app: Express, ctx: RegisterFinalizeRoutes
return sendApiError(res, 400, 'BAD_REQUEST', 'invalid project id');
}
const protocol = req.params.provider;
if (!isFinalizeProviderProtocol(protocol)) {
return sendApiError(
res,
400,
'BAD_REQUEST',
'provider must be one of anthropic|openai|azure|google|ollama',
);
}
if (bodyProtocol !== undefined && bodyProtocol !== protocol) {
return sendApiError(res, 400, 'BAD_REQUEST', 'body protocol must match route provider');
}
if (typeof apiKey !== 'string' || !apiKey.trim()) {
return sendApiError(res, 400, 'BAD_REQUEST', 'apiKey is required');
}
if (typeof model !== 'string' || !model.trim()) {
return sendApiError(res, 400, 'BAD_REQUEST', 'model is required');
}
let effectiveBaseUrl = defaultBaseUrlForFinalizeProtocol(protocol);
if (baseUrl !== undefined) {
if (typeof baseUrl !== 'string' || !baseUrl.trim()) {
return sendApiError(res, 400, 'BAD_REQUEST', 'baseUrl must be a non-empty string when provided');
}
const validated = await validateExternalApiBaseUrl(baseUrl);
if (validated.error) {
return sendApiError(
res,
validated.forbidden ? 403 : 400,
validated.forbidden ? 'FORBIDDEN' : 'BAD_REQUEST',
validated.error,
);
}
effectiveBaseUrl = baseUrl.trim();
}
if (!effectiveBaseUrl) {
return sendApiError(res, 400, 'BAD_REQUEST', 'baseUrl is required for this provider');
}
const validated = await validateExternalApiBaseUrl(effectiveBaseUrl);
if (validated.error) {
return sendApiError(
res,
validated.forbidden ? 403 : 400,
validated.forbidden ? 'FORBIDDEN' : 'BAD_REQUEST',
validated.error,
);
}
if (maxTokens !== undefined && (typeof maxTokens !== 'number' || maxTokens <= 0)) {
return sendApiError(res, 400, 'BAD_REQUEST', 'maxTokens must be a positive number when provided');
}
if (apiVersion !== undefined && typeof apiVersion !== 'string') {
return sendApiError(res, 400, 'BAD_REQUEST', 'apiVersion must be a string when provided');
}
const project = getProject(db, req.params.id);
if (!project) {
@@ -590,7 +618,17 @@ export function registerFinalizeRoutes(app: Express, ctx: RegisterFinalizeRoutes
PROJECTS_DIR,
DESIGN_SYSTEMS_DIR,
req.params.id,
{ apiKey, baseUrl, model, maxTokens, signal: finalizeAbort.signal },
{
protocol,
apiKey,
baseUrl: effectiveBaseUrl,
model,
maxTokens,
...(typeof apiVersion === 'string' && apiVersion.trim()
? { apiVersion: apiVersion.trim() }
: {}),
signal: finalizeAbort.signal,
},
);
} finally {
res.off('close', abortFromRequest);
@@ -604,9 +642,9 @@ export function registerFinalizeRoutes(app: Express, ctx: RegisterFinalizeRoutes
return sendApiError(res, 409, 'CONFLICT', err.message);
}
// Upstream Anthropic error - status-aware mapping using shared
// Upstream provider error - status-aware mapping using shared
// ApiErrorCode values. Run the raw upstream body through
// redactSecrets so the API key cannot leak even if Anthropic
// redactSecrets so the API key cannot leak even if the provider
// echoes the inbound headers. Codes per @lefarcen P2 on PR #832:
// 401 -> UNAUTHORIZED, 429 -> RATE_LIMITED, others -> UPSTREAM_UNAVAILABLE.
if (err instanceof FinalizeUpstreamError) {
@@ -635,7 +673,7 @@ export function registerFinalizeRoutes(app: Express, ctx: RegisterFinalizeRoutes
// Log via console.error per the daemon convention; client sees a
// generic 500 with the shared INTERNAL_ERROR code. Run the message
// through redactSecrets defensively.
console.error('[finalize/anthropic]', err);
console.error('[finalize]', err);
const safeMsg = redactSecrets(String(err?.message || err), [apiKey]);
return sendApiError(res, 500, 'INTERNAL_ERROR', safeMsg);
}
+4
View File
@@ -150,9 +150,11 @@ import {
import { listProviderModels } from './providerModels.js';
import { importClaudeDesignZip } from './claude-design-import.js';
import {
defaultBaseUrlForFinalizeProtocol,
finalizeDesignPackage,
FinalizePackageLockedError,
FinalizeUpstreamError,
isFinalizeProviderProtocol,
} from './finalize-design.js';
import { listPromptTemplates, readPromptTemplate } from './prompt-templates.js';
import { buildDocumentPreview } from './document-preview.js';
@@ -3669,9 +3671,11 @@ export async function startServer({
verifyDesktopImportToken,
};
const finalizeDeps = {
defaultBaseUrlForFinalizeProtocol,
finalizeDesignPackage,
FinalizePackageLockedError,
FinalizeUpstreamError,
isFinalizeProviderProtocol,
redactSecrets,
};
const validationDeps = { isSafeId, validateExternalApiBaseUrl, validateBaseUrl };
+59
View File
@@ -510,6 +510,65 @@ describe('finalizeDesignPackage (pipeline integration)', () => {
expect(dirEntries).not.toContain('.finalize.lock');
});
it('uses Google Gemini generateContent when finalize protocol is google', async () => {
const { db, projectsRoot, designSystemsRoot } = setupPipeline({
designSystemId: 'shadcn',
designSystemBody: '# shadcn\n',
});
const fetchImpl = vi.fn(async (_url: string, _init: RequestInit) =>
new Response(
JSON.stringify({
candidates: [
{
content: {
parts: [
{ text: '# DESIGN.md\n' },
{ text: '## Summary\nGemini synthesis.\n' },
],
},
},
],
usageMetadata: {
promptTokenCount: 4321,
candidatesTokenCount: 876,
},
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
),
);
const result = await finalizeDesignPackage(db, projectsRoot, designSystemsRoot, PROJECT_ID, {
protocol: 'google',
apiKey: 'AIza-test-key',
baseUrl: 'https://generativelanguage.googleapis.com',
model: 'gemini-2.0-flash',
fetchImpl: fetchImpl as any,
} as any);
expect(result.model).toBe('gemini-2.0-flash');
expect(result.inputTokens).toBe(4321);
expect(result.outputTokens).toBe(876);
expect(fs.readFileSync(result.designMdPath, 'utf8')).toBe(
'# DESIGN.md\n## Summary\nGemini synthesis.\n',
);
expect(fetchImpl).toHaveBeenCalledTimes(1);
const [url, init] = fetchImpl.mock.calls[0]!;
expect(url).toBe(
'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent',
);
expect((init as RequestInit).headers).toMatchObject({
'content-type': 'application/json',
'x-goog-api-key': 'AIza-test-key',
});
const body = JSON.parse((init as RequestInit).body as string);
expect(body.systemInstruction.parts[0].text).toContain('# DESIGN.md');
expect(body.contents).toHaveLength(1);
expect(body.contents[0].role).toBe('user');
expect(body.contents[0].parts[0].text).toContain('Synthesize DESIGN.md');
expect(body.generationConfig.maxOutputTokens).toBe(16000);
});
it('response carries every documented field with correct types', async () => {
const { db, projectsRoot, designSystemsRoot } = setupPipeline({
designSystemId: 'shadcn',
@@ -1,5 +1,5 @@
// "Finalize design package" toolbar action — #451. Triggers the
// daemon's POST /api/projects/:id/finalize/anthropic, which
// daemon's POST /api/projects/:id/finalize/<provider>, which
// synchronously synthesizes DESIGN.md from the project transcript +
// active design system + current artifact (route owned by PR #832,
// merged 2026-05-08 by lefarcen).
+5
View File
@@ -2604,11 +2604,16 @@ export function ProjectView({
// shortcut wiring. Close to the JSX so the data flow is easy to
// trace from the toolbar back to its sources.
const handleFinalize = useCallback(() => {
const protocol = config.apiProtocol ?? 'anthropic';
void finalize.trigger({
protocol,
apiKey: config.apiKey,
baseUrl: config.baseUrl,
model: config.model,
maxTokens: effectiveMaxTokens(config),
...(protocol === 'azure' && config.apiVersion?.trim()
? { apiVersion: config.apiVersion.trim() }
: {}),
}).then((result) => {
if (result) void designMdState.refresh();
});
+16 -4
View File
@@ -1,4 +1,4 @@
// Wraps POST /api/projects/:id/finalize/anthropic for the Finalize
// Wraps POST /api/projects/:id/finalize/<provider> for the Finalize
// design package button (#451). The daemon route runs synchronously for
// 60–120 s, so the hook owns:
// - request lifecycle (idle / pending / success / error)
@@ -17,11 +17,19 @@ import type {
ApiErrorCode,
FinalizeAnthropicRequest,
FinalizeAnthropicResponse,
FinalizeProviderProtocol,
} from '@open-design/contracts';
// 130 000 ms = daemon timeout (120 s) + 10 s buffer so the daemon's
// own retry/timeout layer always wins under normal failure modes.
const FETCH_TIMEOUT_MS = 130_000;
const FINALIZE_PROTOCOLS = new Set<FinalizeProviderProtocol>([
'anthropic',
'openai',
'azure',
'google',
'ollama',
]);
export type FinalizeStatus = 'idle' | 'pending' | 'success' | 'error';
@@ -86,10 +94,14 @@ export function useFinalizeProject(projectId: string): FinalizeProjectState {
// setStatus('idle') while the second request is still pending,
// clearing the spinner and re-enabling the buttons mid-flight.
const isCurrent = () => abortRef.current === controller;
const protocol =
typeof req.protocol === 'string' && FINALIZE_PROTOCOLS.has(req.protocol)
? req.protocol
: 'anthropic';
try {
const resp = await fetch(
`/api/projects/${encodeURIComponent(projectId)}/finalize/anthropic`,
`/api/projects/${encodeURIComponent(projectId)}/finalize/${protocol}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -174,9 +186,9 @@ export function messageForCode(code: ApiErrorCode | 'NETWORK_ERROR' | string): s
case 'FORBIDDEN':
return 'Access denied by the upstream API.';
case 'RATE_LIMITED':
return 'Anthropic rate-limited the request. Try again in a minute.';
return 'The selected provider rate-limited the request. Try again in a minute.';
case 'UPSTREAM_UNAVAILABLE':
return 'The Anthropic API is unavailable right now.';
return 'The selected provider API is unavailable right now.';
case 'CONFLICT':
return 'Another finalize is in progress for this project.';
case 'PROJECT_NOT_FOUND':
@@ -15,6 +15,14 @@ const REQUEST = {
maxTokens: 8192,
};
const GOOGLE_REQUEST = {
protocol: 'google',
apiKey: 'AIza-test',
baseUrl: 'https://generativelanguage.googleapis.com',
model: 'gemini-2.0-flash',
maxTokens: 8192,
};
const SUCCESS_BODY = {
designMdPath: '/tmp/p1/DESIGN.md',
bytesWritten: 4096,
@@ -63,12 +71,29 @@ describe('useFinalizeProject', () => {
});
});
it('routes provider-aware requests to the matching finalize endpoint', async () => {
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(jsonResponse({
...SUCCESS_BODY,
model: 'gemini-2.0-flash',
}));
const { result } = renderHook(() => useFinalizeProject('p1'));
await act(async () => {
await result.current.trigger(GOOGLE_REQUEST as any);
});
expect(fetchSpy).toHaveBeenCalledTimes(1);
const [url, init] = fetchSpy.mock.calls[0]!;
expect(url).toBe('/api/projects/p1/finalize/google');
expect(JSON.parse((init as RequestInit).body as string)).toEqual(GOOGLE_REQUEST);
});
const ERROR_TABLE: Array<{ code: string; expected: string }> = [
{ code: 'BAD_REQUEST', expected: 'Bad request — check the API key and model.' },
{ code: 'UNAUTHORIZED', expected: 'API key was rejected. Check it in Settings.' },
{ code: 'FORBIDDEN', expected: 'Access denied by the upstream API.' },
{ code: 'RATE_LIMITED', expected: 'Anthropic rate-limited the request. Try again in a minute.' },
{ code: 'UPSTREAM_UNAVAILABLE', expected: 'The Anthropic API is unavailable right now.' },
{ code: 'RATE_LIMITED', expected: 'The selected provider rate-limited the request. Try again in a minute.' },
{ code: 'UPSTREAM_UNAVAILABLE', expected: 'The selected provider API is unavailable right now.' },
{ code: 'CONFLICT', expected: 'Another finalize is in progress for this project.' },
{ code: 'PROJECT_NOT_FOUND', expected: 'Project not found.' },
{ code: 'INTERNAL_ERROR', expected: 'Something went wrong while finalizing. Check the daemon logs.' },
@@ -130,7 +155,7 @@ describe('useFinalizeProject', () => {
expect(result.current.status).toBe('error');
expect(result.current.error?.code).toBe('UPSTREAM_UNAVAILABLE');
expect(result.current.error?.message).toBe('The Anthropic API is unavailable right now.');
expect(result.current.error?.message).toBe('The selected provider API is unavailable right now.');
expect(result.current.error?.details).toBe(usageCapDetails);
});
+27 -8
View File
@@ -1,7 +1,9 @@
import type { ConnectionTestProtocol } from './connectionTest';
// Shared DTOs for the `/api/projects/:id/finalize/<provider>` family of
// synthesis endpoints. The first endpoint is `/finalize/anthropic`
// (introduced in PR #832); future provider-namespaced siblings
// (`/finalize/openai` etc.) can reuse the request/response shape.
// synthesis endpoints. `/finalize/anthropic` was introduced first; the
// request body now also carries the BYOK protocol so callers can route the
// same finalized-design synthesis through the provider selected in Settings.
/**
* Bumped when the finalize request/response shape changes incompatibly.
@@ -12,21 +14,36 @@
export const FINALIZE_SCHEMA_VERSION = 1;
/**
* Request body for `POST /api/projects/:id/finalize/anthropic`.
* Provider ids supported by the finalized-design synthesis path.
* Matches the BYOK protocols exposed by Settings and connection tests.
*/
export type FinalizeProviderProtocol = ConnectionTestProtocol;
/**
* Request body for `POST /api/projects/:id/finalize/<provider>`.
*
* Field names mirror `ProxyStreamRequest` (./proxy.ts) so a caller that
* already has provider credentials assembled for chat can reuse the
* same shape. `baseUrl` is optional here (intentional divergence from
* the proxy, which requires it) — standard Anthropic users do not need
* to set it; Bedrock / self-hosted-proxy users still can.
* the proxy, which requires it for some providers) — standard provider
* defaults are applied by the daemon when possible.
*/
export interface FinalizeAnthropicRequest {
export interface FinalizeProviderRequest {
/**
* BYOK protocol selected in Settings. Omitted means `anthropic` for
* backward compatibility with the original `/finalize/anthropic` caller.
*/
protocol?: FinalizeProviderProtocol;
apiKey: string;
baseUrl?: string;
model: string;
maxTokens?: number;
/** Azure OpenAI only. Defaults at the daemon when omitted. */
apiVersion?: string;
}
export type FinalizeAnthropicRequest = FinalizeProviderRequest;
/**
* Reference to the artifact that participated in the finalize call, if
* any. Synthesis prompts pass the artifact body verbatim; this response
@@ -45,7 +62,7 @@ export interface FinalizeArtifactRef {
* is the exact UTF-8 byte length on disk. Token counts are echoed
* straight from the provider's `usage` block.
*/
export interface FinalizeAnthropicResponse {
export interface FinalizeProviderResponse {
designMdPath: string;
bytesWritten: number;
model: string;
@@ -55,3 +72,5 @@ export interface FinalizeAnthropicResponse {
transcriptMessageCount: number;
designSystemId: string | null;
}
export type FinalizeAnthropicResponse = FinalizeProviderResponse;