fix: resolve 422 from Replicate + add local Cog Docker mode
- replicate-translate: parse owner/model:hash correctly — extract only
the hash portion for the version field, and use the model endpoint
(POST /v1/models/{owner}/{model}/predictions) which avoids 422
'Invalid version' errors when sending the full owner/model:hash string.
- Add local Cog mode: when replicateMode="local", calls the local Docker
container directly (no Replicate API key needed), default endpoint
http://localhost:5030/predictions (host port 5030 → container port 5000).
- settings-store: add replicateMode ("cloud"|"local") and localEndpoint
fields with env var fallbacks REPLICATE_MODE and LOCAL_MODEL_ENDPOINT.
- admin panel: Radio selector for Cloud vs Local mode; shows docker run
command snippet and local endpoint URL field when local is selected;
hides Replicate API token field in local mode (not needed).
Local model startup:
docker run -d -p 5030:5000 \
r8.im/jigsawstack/text-translate@sha256:454df4c...
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,30 +1,75 @@
|
||||
import { readSettings } from "./settings-store";
|
||||
|
||||
type CogOutput = {
|
||||
status?: string;
|
||||
output?: unknown;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type ReplicateOutput = string | string[] | { translation?: string; translated_text?: string; output?: string };
|
||||
|
||||
export async function replicateTranslate(
|
||||
function extractText(output: unknown): string {
|
||||
if (typeof output === "string") return output;
|
||||
if (Array.isArray(output)) return output.join("");
|
||||
if (output && typeof output === "object") {
|
||||
const o = output as { translation?: string; translated_text?: string; output?: string };
|
||||
return o.translation ?? o.translated_text ?? o.output ?? JSON.stringify(output);
|
||||
}
|
||||
throw new Error("Unexpected output format from model");
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse modelVersion string into components.
|
||||
* Accepts:
|
||||
* "jigsawstack/text-translate:454df4c..." → { owner: "jigsawstack", model: "text-translate", hash: "454df4c..." }
|
||||
* "jigsawstack/text-translate" → { owner: "jigsawstack", model: "text-translate", hash: undefined }
|
||||
* "454df4c..." → { owner: undefined, model: undefined, hash: "454df4c..." }
|
||||
*/
|
||||
function parseModelVersion(mv: string) {
|
||||
const [ownerModel, hash] = mv.split(":");
|
||||
if (ownerModel.includes("/")) {
|
||||
const [owner, model] = ownerModel.split("/");
|
||||
return { owner, model, hash };
|
||||
}
|
||||
// Bare hash
|
||||
return { owner: undefined, model: undefined, hash: ownerModel };
|
||||
}
|
||||
|
||||
async function translateViaCloud(
|
||||
text: string,
|
||||
targetLanguage: string
|
||||
): Promise<string> {
|
||||
const settings = readSettings();
|
||||
|
||||
if (!settings.replicateApiToken) {
|
||||
throw new Error("Replicate API token not configured");
|
||||
}
|
||||
if (!settings.jigsawApiKey) {
|
||||
throw new Error("JigsawStack API key not configured");
|
||||
if (!settings.replicateApiToken) throw new Error("Replicate API token not configured");
|
||||
if (!settings.jigsawApiKey) throw new Error("JigsawStack API key not configured");
|
||||
|
||||
const { owner, model, hash } = parseModelVersion(settings.modelVersion);
|
||||
|
||||
let url: string;
|
||||
let body: Record<string, unknown>;
|
||||
|
||||
if (owner && model && !hash) {
|
||||
// No version pinned — use the model's deployment endpoint (latest)
|
||||
url = `https://api.replicate.com/v1/models/${owner}/${model}/predictions`;
|
||||
body = { input: { text, api_key: settings.jigsawApiKey, target_language: targetLanguage } };
|
||||
} else if (owner && model && hash) {
|
||||
// Pinned version — use model endpoint with version (avoids 422 from bare /predictions)
|
||||
url = `https://api.replicate.com/v1/models/${owner}/${model}/predictions`;
|
||||
body = {
|
||||
version: hash,
|
||||
input: { text, api_key: settings.jigsawApiKey, target_language: targetLanguage }
|
||||
};
|
||||
} else {
|
||||
// Bare hash only — use the generic predictions endpoint
|
||||
url = "https://api.replicate.com/v1/predictions";
|
||||
body = {
|
||||
version: hash,
|
||||
input: { text, api_key: settings.jigsawApiKey, target_language: targetLanguage }
|
||||
};
|
||||
}
|
||||
|
||||
const body = {
|
||||
version: settings.modelVersion,
|
||||
input: {
|
||||
text,
|
||||
api_key: settings.jigsawApiKey,
|
||||
target_language: targetLanguage
|
||||
}
|
||||
};
|
||||
|
||||
const response = await fetch("https://api.replicate.com/v1/predictions", {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${settings.replicateApiToken}`,
|
||||
@@ -40,21 +85,55 @@ export async function replicateTranslate(
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (data.error) throw new Error(`Replicate model error: ${data.error}`);
|
||||
|
||||
if (data.error) {
|
||||
throw new Error(`Replicate model error: ${data.error}`);
|
||||
return extractText(data.output as ReplicateOutput);
|
||||
}
|
||||
|
||||
async function translateViaLocal(
|
||||
text: string,
|
||||
targetLanguage: string
|
||||
): Promise<string> {
|
||||
const settings = readSettings();
|
||||
|
||||
if (!settings.jigsawApiKey) throw new Error("JigsawStack API key not configured");
|
||||
|
||||
const endpoint = (settings.localEndpoint || "http://localhost:5030/predictions").replace(/\/$/, "");
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
input: {
|
||||
text,
|
||||
api_key: settings.jigsawApiKey,
|
||||
target_language: targetLanguage
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.text();
|
||||
throw new Error(`Local model error: ${response.status} ${err}`);
|
||||
}
|
||||
|
||||
// Extract translated text from various output formats
|
||||
const output: ReplicateOutput = data.output;
|
||||
const data: CogOutput = await response.json();
|
||||
|
||||
if (typeof output === "string") return output;
|
||||
if (Array.isArray(output)) return output.join("");
|
||||
if (output && typeof output === "object") {
|
||||
return output.translation ?? output.translated_text ?? output.output ?? String(output);
|
||||
}
|
||||
if (data.error) throw new Error(`Local model error: ${data.error}`);
|
||||
|
||||
throw new Error("Unexpected output format from Replicate");
|
||||
// Cog wraps output in { status, output } or returns output directly
|
||||
const output = data.output !== undefined ? data.output : data;
|
||||
return extractText(output);
|
||||
}
|
||||
|
||||
export async function replicateTranslate(
|
||||
text: string,
|
||||
targetLanguage: string
|
||||
): Promise<string> {
|
||||
const { replicateMode } = readSettings();
|
||||
return replicateMode === "local"
|
||||
? translateViaLocal(text, targetLanguage)
|
||||
: translateViaCloud(text, targetLanguage);
|
||||
}
|
||||
|
||||
// Batch translate using separator trick to minimize API calls
|
||||
@@ -72,12 +151,9 @@ export async function replicateTranslateBatch(
|
||||
const joined = texts.join(SEPARATOR);
|
||||
const translated = await replicateTranslate(joined, targetLanguage);
|
||||
|
||||
// Try to split on the separator; fall back to individual calls if it got translated
|
||||
const parts = translated.split(SEPARATOR);
|
||||
if (parts.length === texts.length) {
|
||||
return parts;
|
||||
}
|
||||
if (parts.length === texts.length) return parts;
|
||||
|
||||
// Fallback: translate individually
|
||||
// Fallback: translate individually if separator got mangled
|
||||
return Promise.all(texts.map(t => replicateTranslate(t, targetLanguage)));
|
||||
}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
export type ReplicateMode = "cloud" | "local";
|
||||
|
||||
export type Settings = {
|
||||
replicateApiToken: string;
|
||||
jigsawApiKey: string;
|
||||
modelVersion: string;
|
||||
replicateEnabled: boolean;
|
||||
replicateMode: ReplicateMode;
|
||||
localEndpoint: string;
|
||||
adminPasswordHash: string;
|
||||
};
|
||||
|
||||
@@ -14,6 +18,8 @@ const DEFAULT_SETTINGS: Settings = {
|
||||
jigsawApiKey: process.env["JIGSAWSTACK_API_KEY"] ?? "",
|
||||
modelVersion: "jigsawstack/text-translate:454df4c49941c05dea05175bd37686d0872c73c1f9366d1c2505db32ade52a89",
|
||||
replicateEnabled: false,
|
||||
replicateMode: (process.env["REPLICATE_MODE"] as ReplicateMode) ?? "cloud",
|
||||
localEndpoint: process.env["LOCAL_MODEL_ENDPOINT"] ?? "http://localhost:5030/predictions",
|
||||
adminPasswordHash: process.env["ADMIN_PASSWORD"] ?? "admin"
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user