@forgezero/providers 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/binance.js +1 -1
- package/dist/chain.js +1 -1
- package/dist/database.js +1 -1
- package/dist/email.js +1 -1
- package/dist/http.js +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/pool.js +1 -1
- package/dist/storage.js +1 -1
- package/dist/translation.d.ts +70 -0
- package/dist/translation.js +244 -0
- package/package.json +10 -6
package/README.md
CHANGED
|
@@ -67,9 +67,9 @@ vendor you turned off is not a vendor that is failing.
|
|
|
67
67
|
| `/pool` | which outbound address a request leaves by, sticky per key |
|
|
68
68
|
| `/storage` | S3-compatible object storage, SigV4 signed with Web Crypto, no vendor SDK |
|
|
69
69
|
|
|
70
|
-
Full documentation: **https://forgezero.net/docs/providers**
|
|
70
|
+
Full documentation: **https://www.forgezero.net/docs/providers**
|
|
71
71
|
|
|
72
72
|
## Licence
|
|
73
73
|
|
|
74
|
-
MIT. Part of [ForgeZero](https://forgezero.net) — secrets, attested compute and
|
|
74
|
+
MIT. Part of [ForgeZero](https://www.forgezero.net) — secrets, attested compute and
|
|
75
75
|
deploys — and usable entirely on its own, with no ForgeZero account.
|
package/dist/binance.js
CHANGED
package/dist/chain.js
CHANGED
package/dist/database.js
CHANGED
package/dist/email.js
CHANGED
package/dist/http.js
CHANGED
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
package/dist/pool.js
CHANGED
package/dist/storage.js
CHANGED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { defineProvider } from './index';
|
|
2
|
+
/**
|
|
3
|
+
* Machine translation, as a service with providers behind it.
|
|
4
|
+
*
|
|
5
|
+
* ForgeZero does not translate anything at runtime — its own catalogues are
|
|
6
|
+
* filled in out of band, before a build. This exists because a TENANT may want
|
|
7
|
+
* it, and the alternative is every tenant writing the same retry, pacing and
|
|
8
|
+
* quota handling against whichever vendor they already pay for.
|
|
9
|
+
*
|
|
10
|
+
* Nothing here is specific to Google beyond the one adapter at the bottom. The
|
|
11
|
+
* service contract is "given source text and a target language, return the
|
|
12
|
+
* translated strings in order", and a tenant that prefers a different vendor
|
|
13
|
+
* writes forty lines rather than adopting ours.
|
|
14
|
+
*
|
|
15
|
+
* ## Why order matters in the contract
|
|
16
|
+
*
|
|
17
|
+
* Returning a map keyed by source string looks friendlier and loses duplicates:
|
|
18
|
+
* two identical English strings in different contexts are two entries in a
|
|
19
|
+
* catalogue and must stay two. Positional results keep them distinct.
|
|
20
|
+
*/
|
|
21
|
+
export interface TranslateRequest {
|
|
22
|
+
/** Source strings, in catalogue order. Duplicates are meaningful. */
|
|
23
|
+
texts: readonly string[];
|
|
24
|
+
/** BCP-47 code the texts are written in. */
|
|
25
|
+
from: string;
|
|
26
|
+
/** BCP-47 code to translate into. */
|
|
27
|
+
to: string;
|
|
28
|
+
/**
|
|
29
|
+
* What the strings are for, passed to the model as context.
|
|
30
|
+
*
|
|
31
|
+
* "Sign in" is a button in one product and an instruction in another, and a
|
|
32
|
+
* translator with no context picks wrong in exactly the places a user
|
|
33
|
+
* notices.
|
|
34
|
+
*/
|
|
35
|
+
context?: string;
|
|
36
|
+
}
|
|
37
|
+
export interface TranslateResult {
|
|
38
|
+
/** One entry per input, in the same order. */
|
|
39
|
+
texts: string[];
|
|
40
|
+
/** What actually served the request, for a log somebody has to read later. */
|
|
41
|
+
model?: string;
|
|
42
|
+
}
|
|
43
|
+
/** Anything that can translate. A tenant may supply their own. */
|
|
44
|
+
export type TranslationProvider = ReturnType<typeof defineProvider<TranslateRequest, TranslateResult>>;
|
|
45
|
+
/**
|
|
46
|
+
* Google AI Studio, on the free tier.
|
|
47
|
+
*
|
|
48
|
+
* Chosen as the first adapter because the free tier is generous enough for a
|
|
49
|
+
* catalogue and needs no billing account — a tenant can try the feature without
|
|
50
|
+
* a procurement conversation.
|
|
51
|
+
*
|
|
52
|
+
* Structured output is requested explicitly. Asking a model for "JSON" in prose
|
|
53
|
+
* and parsing whatever comes back is how translation pipelines start returning
|
|
54
|
+
* markdown fences on a Tuesday; a schema makes the shape the model's problem.
|
|
55
|
+
*/
|
|
56
|
+
export declare const googleAiStudio: import("./index").ProviderSpec<TranslateRequest, TranslateResult>;
|
|
57
|
+
export declare class TranslationError extends Error {
|
|
58
|
+
readonly code: 'NO_CREDENTIAL' | 'RATE_LIMITED' | 'REQUEST_FAILED' | 'EMPTY_RESPONSE' | 'LENGTH_MISMATCH';
|
|
59
|
+
readonly status?: number | undefined;
|
|
60
|
+
constructor(code: 'NO_CREDENTIAL' | 'RATE_LIMITED' | 'REQUEST_FAILED' | 'EMPTY_RESPONSE' | 'LENGTH_MISMATCH', message: string, status?: number | undefined);
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Read an array of strings out of a model response.
|
|
64
|
+
*
|
|
65
|
+
* Fences are stripped defensively: models that honour `responseSchema` never
|
|
66
|
+
* emit them, and models that ignore it wrap the same JSON in ```json. Handling
|
|
67
|
+
* both costs three lines and removes a class of "worked until we changed model".
|
|
68
|
+
*/
|
|
69
|
+
export declare function parseStrings(raw: string): string[];
|
|
70
|
+
export declare const translationProviders: readonly [import("./index").ProviderSpec<TranslateRequest, TranslateResult>];
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
class ProviderError extends Error {
|
|
3
|
+
code;
|
|
4
|
+
details;
|
|
5
|
+
constructor(code, message, details) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.code = code;
|
|
8
|
+
this.details = details;
|
|
9
|
+
this.name = "ProviderError";
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
function envCredentials(env) {
|
|
13
|
+
return {
|
|
14
|
+
name: "env",
|
|
15
|
+
async get(reference, field) {
|
|
16
|
+
const key = `${reference}_${field}`.replace(/[.-]/g, "_").toUpperCase();
|
|
17
|
+
const value = env[key];
|
|
18
|
+
if (value === undefined) {
|
|
19
|
+
throw new ProviderError("CREDENTIAL_MISSING", `Set ${key} in the environment.`);
|
|
20
|
+
}
|
|
21
|
+
return value;
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
function chainCredentials(...sources) {
|
|
26
|
+
return {
|
|
27
|
+
name: sources.map((source) => source.name).join("+"),
|
|
28
|
+
async get(reference, field) {
|
|
29
|
+
let last;
|
|
30
|
+
for (const source of sources) {
|
|
31
|
+
try {
|
|
32
|
+
return await source.get(reference, field);
|
|
33
|
+
} catch (error) {
|
|
34
|
+
last = error;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
throw last instanceof Error ? last : new ProviderError("CREDENTIAL_MISSING", `No source held ${reference}.${field}.`);
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
function staticConfig(services) {
|
|
42
|
+
const health = new Map;
|
|
43
|
+
return {
|
|
44
|
+
name: "static",
|
|
45
|
+
async list(serviceKey) {
|
|
46
|
+
return (services[serviceKey] ?? []).map((provider) => ({
|
|
47
|
+
...provider,
|
|
48
|
+
health: health.get(`${serviceKey}:${provider.providerId}`) ?? provider.health
|
|
49
|
+
}));
|
|
50
|
+
},
|
|
51
|
+
async recordHealth(serviceKey, providerId, next) {
|
|
52
|
+
health.set(`${serviceKey}:${providerId}`, next);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
function defineProvider(spec) {
|
|
57
|
+
return spec;
|
|
58
|
+
}
|
|
59
|
+
var STRIKES_TO_OFFLINE = 3;
|
|
60
|
+
function nextHealth(current, kind) {
|
|
61
|
+
if (kind === "success")
|
|
62
|
+
return { strikes: 0, status: "ok" };
|
|
63
|
+
if (kind === "backoff")
|
|
64
|
+
return current ?? { strikes: 0, status: "ok" };
|
|
65
|
+
const strikes = (current?.strikes ?? 0) + 1;
|
|
66
|
+
return {
|
|
67
|
+
strikes,
|
|
68
|
+
status: strikes >= STRIKES_TO_OFFLINE ? "offline" : "degraded",
|
|
69
|
+
lastFailureAtTs: Date.now()
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
function createRegistry(options) {
|
|
73
|
+
const byId = new Map(options.providers.map((provider) => [provider.id, provider]));
|
|
74
|
+
async function call(serviceKey, args) {
|
|
75
|
+
const configured = [...await options.config.list(serviceKey)].filter((provider) => provider.enabled).sort((a, b) => a.priority - b.priority);
|
|
76
|
+
const attempts = [];
|
|
77
|
+
for (const entry of configured) {
|
|
78
|
+
const spec = byId.get(entry.providerId);
|
|
79
|
+
if (!spec) {
|
|
80
|
+
attempts.push({ providerId: entry.providerId, outcome: "skipped", error: "not registered" });
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (entry.health?.status === "offline") {
|
|
84
|
+
attempts.push({ providerId: entry.providerId, outcome: "skipped", error: "offline" });
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
options.before?.({ service: serviceKey, provider: entry.providerId });
|
|
88
|
+
try {
|
|
89
|
+
const result = await spec.invoke({
|
|
90
|
+
config: entry.config,
|
|
91
|
+
secret: (field) => options.credentials.get(entry.secretRef, field)
|
|
92
|
+
}, args);
|
|
93
|
+
attempts.push({ providerId: entry.providerId, outcome: "sent" });
|
|
94
|
+
await options.config.recordHealth(serviceKey, entry.providerId, nextHealth(entry.health, "success"));
|
|
95
|
+
const sent = { ok: true, result, provider: entry.providerId, attempts };
|
|
96
|
+
options.after?.(sent);
|
|
97
|
+
return sent;
|
|
98
|
+
} catch (error) {
|
|
99
|
+
const kind = spec.classify(error);
|
|
100
|
+
attempts.push({
|
|
101
|
+
providerId: entry.providerId,
|
|
102
|
+
outcome: "failed",
|
|
103
|
+
kind,
|
|
104
|
+
error: error instanceof Error ? error.message : String(error)
|
|
105
|
+
});
|
|
106
|
+
await options.config.recordHealth(serviceKey, entry.providerId, nextHealth(entry.health, kind));
|
|
107
|
+
if (kind === "terminal") {
|
|
108
|
+
const refused = {
|
|
109
|
+
ok: false,
|
|
110
|
+
attempts,
|
|
111
|
+
error: new ProviderError("PAYLOAD_REJECTED", "The request was refused as malformed; no provider will accept it.")
|
|
112
|
+
};
|
|
113
|
+
options.after?.(refused);
|
|
114
|
+
return refused;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const failed = {
|
|
119
|
+
ok: false,
|
|
120
|
+
attempts,
|
|
121
|
+
error: new ProviderError(attempts.length === 0 ? "NO_PROVIDER" : "ALL_PROVIDERS_FAILED", attempts.length === 0 ? `No provider is configured for "${serviceKey}".` : `Every provider for "${serviceKey}" failed or was skipped.`)
|
|
122
|
+
};
|
|
123
|
+
options.after?.(failed);
|
|
124
|
+
return failed;
|
|
125
|
+
}
|
|
126
|
+
return { call };
|
|
127
|
+
}
|
|
128
|
+
var VERSION = "0.1.2";
|
|
129
|
+
|
|
130
|
+
// src/translation.ts
|
|
131
|
+
var GOOGLE_ENDPOINT = "https://generativelanguage.googleapis.com/v1beta/models";
|
|
132
|
+
var googleAiStudio = defineProvider({
|
|
133
|
+
id: "google-ai-studio",
|
|
134
|
+
service: "translation",
|
|
135
|
+
label: "Google AI Studio",
|
|
136
|
+
credentials: {
|
|
137
|
+
type: "object",
|
|
138
|
+
required: ["apiKey"],
|
|
139
|
+
properties: {
|
|
140
|
+
apiKey: {
|
|
141
|
+
type: "string",
|
|
142
|
+
title: "API key",
|
|
143
|
+
writeOnly: true,
|
|
144
|
+
description: "From aistudio.google.com. The free tier is enough for a catalogue."
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
config: {
|
|
149
|
+
type: "object",
|
|
150
|
+
properties: {
|
|
151
|
+
model: {
|
|
152
|
+
type: "string",
|
|
153
|
+
title: "Model",
|
|
154
|
+
default: "gemini-2.0-flash",
|
|
155
|
+
description: "Any model this key can reach. Flash tiers are fastest and cheapest."
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
async invoke(context, args) {
|
|
160
|
+
const apiKey = await context.secret("apiKey");
|
|
161
|
+
if (!apiKey)
|
|
162
|
+
throw new TranslationError("NO_CREDENTIAL", "No API key is configured.");
|
|
163
|
+
const model = String(context.config?.model ?? "gemini-2.0-flash");
|
|
164
|
+
const response = await fetch(`${GOOGLE_ENDPOINT}/${model}:generateContent`, {
|
|
165
|
+
method: "POST",
|
|
166
|
+
headers: { "content-type": "application/json", "x-goog-api-key": apiKey },
|
|
167
|
+
signal: context.signal,
|
|
168
|
+
body: JSON.stringify({
|
|
169
|
+
contents: [
|
|
170
|
+
{
|
|
171
|
+
parts: [
|
|
172
|
+
{
|
|
173
|
+
text: [
|
|
174
|
+
`Translate each string from ${args.from} to ${args.to}.`,
|
|
175
|
+
args.context ? `They appear in: ${args.context}.` : "",
|
|
176
|
+
"Return exactly one translation per input, in the same order.",
|
|
177
|
+
"Preserve placeholders such as {0} or {name} exactly as written.",
|
|
178
|
+
"Translate nothing that is a placeholder, a URL, or a code identifier.",
|
|
179
|
+
"",
|
|
180
|
+
JSON.stringify(args.texts)
|
|
181
|
+
].filter(Boolean).join(`
|
|
182
|
+
`)
|
|
183
|
+
}
|
|
184
|
+
]
|
|
185
|
+
}
|
|
186
|
+
],
|
|
187
|
+
generationConfig: {
|
|
188
|
+
responseMimeType: "application/json",
|
|
189
|
+
responseSchema: {
|
|
190
|
+
type: "ARRAY",
|
|
191
|
+
items: { type: "STRING" }
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
})
|
|
195
|
+
});
|
|
196
|
+
if (!response.ok) {
|
|
197
|
+
throw new TranslationError(response.status === 429 ? "RATE_LIMITED" : "REQUEST_FAILED", `Google AI Studio answered ${response.status}.`, response.status);
|
|
198
|
+
}
|
|
199
|
+
const payload = await response.json();
|
|
200
|
+
const raw = payload.candidates?.[0]?.content?.parts?.[0]?.text;
|
|
201
|
+
if (!raw)
|
|
202
|
+
throw new TranslationError("EMPTY_RESPONSE", "The model returned nothing.");
|
|
203
|
+
const texts = parseStrings(raw);
|
|
204
|
+
if (texts.length !== args.texts.length) {
|
|
205
|
+
throw new TranslationError("LENGTH_MISMATCH", `Asked for ${args.texts.length} translations and received ${texts.length}.`);
|
|
206
|
+
}
|
|
207
|
+
return { texts, model };
|
|
208
|
+
},
|
|
209
|
+
classify(error) {
|
|
210
|
+
if (error instanceof TranslationError) {
|
|
211
|
+
if (error.code === "RATE_LIMITED")
|
|
212
|
+
return "backoff";
|
|
213
|
+
if (error.code === "NO_CREDENTIAL" || error.code === "LENGTH_MISMATCH")
|
|
214
|
+
return "terminal";
|
|
215
|
+
}
|
|
216
|
+
return "retryable";
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
class TranslationError extends Error {
|
|
221
|
+
code;
|
|
222
|
+
status;
|
|
223
|
+
constructor(code, message, status) {
|
|
224
|
+
super(message);
|
|
225
|
+
this.code = code;
|
|
226
|
+
this.status = status;
|
|
227
|
+
this.name = "TranslationError";
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
function parseStrings(raw) {
|
|
231
|
+
const cleaned = raw.trim().replace(/^```(?:json)?/i, "").replace(/```$/, "").trim();
|
|
232
|
+
const parsed = JSON.parse(cleaned);
|
|
233
|
+
if (!Array.isArray(parsed) || parsed.some((entry) => typeof entry !== "string")) {
|
|
234
|
+
throw new TranslationError("EMPTY_RESPONSE", "The model did not return a list of strings.");
|
|
235
|
+
}
|
|
236
|
+
return parsed;
|
|
237
|
+
}
|
|
238
|
+
var translationProviders = [googleAiStudio];
|
|
239
|
+
export {
|
|
240
|
+
translationProviders,
|
|
241
|
+
parseStrings,
|
|
242
|
+
googleAiStudio,
|
|
243
|
+
TranslationError
|
|
244
|
+
};
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"//": "Publishing happens from an operator's machine, not CI \u2014 CLAUDE.md records that the absence of CI is deliberate. npm's `provenance` attests a tarball was built by a recognised CI provider from a named commit, so it cannot be produced here: it was set, and the first publish failed with `Automatic provenance generation not supported for provider: null`. A setting that can never be satisfied is worse than none, because it reads as a guarantee nobody is getting. Restore it the day this publishes from CI, and not before.",
|
|
3
3
|
"name": "@forgezero/providers",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.2",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
7
7
|
"access": "public"
|
|
@@ -38,11 +38,15 @@
|
|
|
38
38
|
"./binance": {
|
|
39
39
|
"types": "./dist/binance.d.ts",
|
|
40
40
|
"default": "./dist/binance.js"
|
|
41
|
+
},
|
|
42
|
+
"./translation": {
|
|
43
|
+
"types": "./dist/translation.d.ts",
|
|
44
|
+
"default": "./dist/translation.js"
|
|
41
45
|
}
|
|
42
46
|
},
|
|
43
47
|
"scripts": {
|
|
44
48
|
"check": "tsc --noEmit",
|
|
45
|
-
"build": "bun build src/index.ts src/email.ts src/chain.ts src/database.ts src/http.ts src/pool.ts src/storage.ts src/binance.ts --root src --outdir dist --target browser --format esm --packages external && tsc --emitDeclarationOnly --declaration --noEmit false --outDir dist",
|
|
49
|
+
"build": "bun build src/index.ts src/email.ts src/chain.ts src/database.ts src/http.ts src/pool.ts src/storage.ts src/binance.ts src/translation.ts --root src --outdir dist --target browser --format esm --packages external && tsc --emitDeclarationOnly --declaration --noEmit false --outDir dist",
|
|
46
50
|
"prepublishOnly": "bun run check && bun run build"
|
|
47
51
|
},
|
|
48
52
|
"devDependencies": {
|
|
@@ -62,13 +66,13 @@
|
|
|
62
66
|
"object-storage"
|
|
63
67
|
],
|
|
64
68
|
"license": "MIT",
|
|
65
|
-
"homepage": "https://forgezero.net/docs/providers",
|
|
69
|
+
"homepage": "https://www.forgezero.net/docs/providers",
|
|
66
70
|
"repository": {
|
|
67
71
|
"type": "git",
|
|
68
|
-
"url": "git+https://github.com/
|
|
72
|
+
"url": "git+https://github.com/forgezero-net/packages.git",
|
|
69
73
|
"directory": "packages/providers"
|
|
70
74
|
},
|
|
71
|
-
"bugs": "https://github.com/
|
|
75
|
+
"bugs": "https://github.com/forgezero-net/packages/issues",
|
|
72
76
|
"sideEffects": false,
|
|
73
77
|
"types": "./dist/index.d.ts",
|
|
74
78
|
"files": [
|
|
@@ -77,6 +81,6 @@
|
|
|
77
81
|
"LICENSE"
|
|
78
82
|
],
|
|
79
83
|
"dependencies": {
|
|
80
|
-
"@forgezero/runtime": "^0.1.
|
|
84
|
+
"@forgezero/runtime": "^0.1.2"
|
|
81
85
|
}
|
|
82
86
|
}
|