mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
Fix finalize provider routing
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
co-authored by
multica-agent
parent
e93583c550
commit
6c334e08d1
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user