opencode-translate 0.0.1

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/src/protect.ts ADDED
@@ -0,0 +1,285 @@
1
+ import { PLACEHOLDER_PATTERN } from "./constants"
2
+
3
+ type Segment = { type: "text"; value: string } | { type: "placeholder"; value: string }
4
+
5
+ interface PlaceholderEntry {
6
+ token: string
7
+ kind: string
8
+ original: string
9
+ }
10
+
11
+ export interface ProtectionPlan {
12
+ text: string
13
+ placeholders: PlaceholderEntry[]
14
+ counts: {
15
+ fencedCodeBlocks: number
16
+ urls: number
17
+ paths: number
18
+ }
19
+ }
20
+
21
+ export interface RestoreFailure {
22
+ ok: false
23
+ missing: string[]
24
+ extra: string[]
25
+ duplicated: string[]
26
+ reason: string
27
+ }
28
+
29
+ export interface RestoreSuccess {
30
+ ok: true
31
+ text: string
32
+ }
33
+
34
+ export type RestoreResult = RestoreSuccess | RestoreFailure
35
+
36
+ const RELATIVE_PATH_EXTENSIONS = [
37
+ "c",
38
+ "cc",
39
+ "cpp",
40
+ "css",
41
+ "go",
42
+ "h",
43
+ "hpp",
44
+ "html",
45
+ "ini",
46
+ "java",
47
+ "js",
48
+ "json",
49
+ "jsx",
50
+ "kt",
51
+ "md",
52
+ "py",
53
+ "rs",
54
+ "sh",
55
+ "sql",
56
+ "swift",
57
+ "toml",
58
+ "ts",
59
+ "tsx",
60
+ "xml",
61
+ "yaml",
62
+ "yml",
63
+ "zsh",
64
+ ].join("|")
65
+
66
+ function placeholderToken(kind: string, index: number): string {
67
+ return `⟦OCTX:${kind}:${index}⟧`
68
+ }
69
+
70
+ function replaceWithPlaceholders(
71
+ segments: Segment[],
72
+ kind: string,
73
+ expression: RegExp,
74
+ startIndex: number,
75
+ filter?: (match: string) => boolean,
76
+ ): { segments: Segment[]; nextIndex: number } {
77
+ let nextIndex = startIndex
78
+ const nextSegments: Segment[] = []
79
+
80
+ for (const segment of segments) {
81
+ if (segment.type === "placeholder") {
82
+ nextSegments.push(segment)
83
+ continue
84
+ }
85
+
86
+ const source = segment.value
87
+ expression.lastIndex = 0
88
+ let cursor = 0
89
+ let matched = false
90
+ let match = expression.exec(source)
91
+
92
+ while (match !== null) {
93
+ const value = match[0]
94
+ if (!value) {
95
+ expression.lastIndex += 1
96
+ match = expression.exec(source)
97
+ continue
98
+ }
99
+ if (filter && !filter(value)) {
100
+ match = expression.exec(source)
101
+ continue
102
+ }
103
+
104
+ matched = true
105
+ if (match.index > cursor) {
106
+ nextSegments.push({ type: "text", value: source.slice(cursor, match.index) })
107
+ }
108
+
109
+ const token = placeholderToken(kind, nextIndex)
110
+ nextSegments.push({ type: "placeholder", value: JSON.stringify({ token, kind, original: value }) })
111
+ nextIndex += 1
112
+ cursor = match.index + value.length
113
+ match = expression.exec(source)
114
+ }
115
+
116
+ if (!matched) {
117
+ nextSegments.push(segment)
118
+ continue
119
+ }
120
+
121
+ if (cursor < source.length) {
122
+ nextSegments.push({ type: "text", value: source.slice(cursor) })
123
+ }
124
+ }
125
+
126
+ return { segments: nextSegments, nextIndex }
127
+ }
128
+
129
+ function deserializeSegments(segments: Segment[]): { plain: string; placeholders: PlaceholderEntry[] } {
130
+ const placeholders: PlaceholderEntry[] = []
131
+ const plain = segments
132
+ .map((segment) => {
133
+ if (segment.type === "text") return segment.value
134
+ const record = JSON.parse(segment.value) as PlaceholderEntry
135
+ placeholders.push(record)
136
+ return record.token
137
+ })
138
+ .join("")
139
+
140
+ return { plain, placeholders }
141
+ }
142
+
143
+ function countMatches(text: string, pattern: RegExp): number {
144
+ pattern.lastIndex = 0
145
+ let count = 0
146
+ while (pattern.exec(text)) count += 1
147
+ return count
148
+ }
149
+
150
+ function countPaths(text: string): number {
151
+ const patterns = [
152
+ /(?<![A-Za-z0-9_.~-])\/[A-Za-z0-9._~\-/]+/g,
153
+ /(?<![A-Za-z0-9_.~-])[A-Za-z]:\\[^\s"'`<>]+/g,
154
+ new RegExp(
155
+ `${String.raw`(?<![A-Za-z0-9_.~\-/])(?:\.\.?[\\/])?(?:[^\s"'`}\`${String.raw`<>]+[\\/])+[^\s"'`}\`${String.raw`<>]+\.(?:${RELATIVE_PATH_EXTENSIONS})\b`}`,
156
+ "g",
157
+ ),
158
+ ]
159
+
160
+ return patterns.reduce((sum, pattern) => sum + countMatches(text, pattern), 0)
161
+ }
162
+
163
+ export function protectText(text: string): ProtectionPlan {
164
+ let segments: Segment[] = [{ type: "text", value: text }]
165
+ const placeholderEntries: PlaceholderEntry[] = []
166
+ let placeholderIndex = 0
167
+ let fencedCodeBlocks = 0
168
+ let urls = 0
169
+ let paths = 0
170
+
171
+ const apply = (kind: string, expression: RegExp, filter?: (match: string) => boolean) => {
172
+ const result = replaceWithPlaceholders(segments, kind, expression, placeholderIndex, filter)
173
+ segments = result.segments
174
+ placeholderIndex = result.nextIndex
175
+ }
176
+
177
+ apply("fenced-code", /(?:^|\n)(?:```|~~~)[^\n]*\n[\s\S]*?\n(?:```|~~~)(?=\n|$)/g)
178
+ apply("inline-code", /`[^`\n]+`/g)
179
+ apply("url", /(?:https?:\/\/|wss?:\/\/|file:\/\/|mailto:)[^\s<>()]+/g)
180
+ apply("path-posix", /(?<![A-Za-z0-9_.~-])\/[A-Za-z0-9._~\-/]+/g)
181
+ apply("path-windows", /(?<![A-Za-z0-9_.~-])[A-Za-z]:\\[^\s"'`<>]+/g)
182
+ apply(
183
+ "path-relative",
184
+ new RegExp(
185
+ `${String.raw`(?<![A-Za-z0-9_.~\-/])(?:\.\.?[\\/])?(?:[^\s"'`}\`${String.raw`<>]+[\\/])+[^\s"'`}\`${String.raw`<>]+\.(?:${RELATIVE_PATH_EXTENSIONS})\b`}`,
186
+ "g",
187
+ ),
188
+ )
189
+ apply("env", /\$(?:\{[A-Z_][A-Z0-9_]*\}|[A-Z_][A-Z0-9_]*)|%[A-Z_][A-Z0-9_]*%/g)
190
+ apply("stack-frame", /^(?: {0,4}at .+?:\d+:\d+.*)$/gm)
191
+ apply(
192
+ "diff",
193
+ /^(?:(?:@@ .*)|(?:\+\+\+ .*)|(?:--- .*)|(?:\+.*)|(?:-.*))(?:\n(?:(?:@@ .*)|(?:\+\+\+ .*)|(?:--- .*)|(?:\+.*)|(?:-.*)))*$/gm,
194
+ )
195
+ apply("json-key", /(?<=^|\n)[ \t]*(?:"[^"\n]+"|'[^'\n]+'|[A-Za-z0-9_.-]+)(?=:\s*)/g)
196
+ apply("tag", /<[^>\n]+>/g)
197
+ apply("prompt-marker", /<!-- oc-translate:[^>\n]*-->/g)
198
+ apply("reference", /(?:@[A-Za-z0-9_.-]+|#[0-9]+|\b[0-9a-f]{7,40}\b)/g)
199
+ apply(
200
+ "identifier",
201
+ /\b(?:[a-z][A-Za-z0-9]*[A-Z][A-Za-z0-9]*|[A-Z][A-Za-z0-9]*[a-z][A-Za-z0-9]*|[a-z0-9]+(?:_[a-z0-9]+)+|[a-z0-9]+(?:-[a-z0-9]+)+|[A-Z0-9]+(?:_[A-Z0-9]+)+)\b/g,
202
+ (match) => match.length >= 3,
203
+ )
204
+
205
+ const { plain, placeholders } = deserializeSegments(segments)
206
+ placeholderEntries.push(...placeholders)
207
+ fencedCodeBlocks = countMatches(text, /(?:^|\n)(?:```|~~~)[^\n]*\n[\s\S]*?\n(?:```|~~~)(?=\n|$)/g)
208
+ urls = countMatches(text, /(?:https?:\/\/|wss?:\/\/|file:\/\/|mailto:)[^\s<>()]+/g)
209
+ paths = countPaths(text)
210
+
211
+ return {
212
+ text: plain,
213
+ placeholders: placeholderEntries,
214
+ counts: {
215
+ fencedCodeBlocks,
216
+ urls,
217
+ paths,
218
+ },
219
+ }
220
+ }
221
+
222
+ export function restoreProtectedText(plan: ProtectionPlan, translated: string): RestoreResult {
223
+ const placeholders = translated.match(PLACEHOLDER_PATTERN) ?? []
224
+ const counts = new Map<string, number>()
225
+ for (const token of placeholders) {
226
+ counts.set(token, (counts.get(token) ?? 0) + 1)
227
+ }
228
+
229
+ const expected = new Set(plan.placeholders.map((entry) => entry.token))
230
+ const missing = plan.placeholders.map((entry) => entry.token).filter((token) => counts.get(token) !== 1)
231
+ const duplicated = [...counts.entries()].filter(([, count]) => count > 1).map(([token]) => token)
232
+ const extra = [...counts.keys()].filter((token) => !expected.has(token))
233
+
234
+ if (missing.length > 0 || duplicated.length > 0 || extra.length > 0) {
235
+ return {
236
+ ok: false,
237
+ missing,
238
+ duplicated,
239
+ extra,
240
+ reason: "placeholder mismatch",
241
+ }
242
+ }
243
+
244
+ let restored = translated
245
+ for (const entry of plan.placeholders) {
246
+ restored = restored.replaceAll(entry.token, entry.original)
247
+ }
248
+
249
+ if (
250
+ countMatches(restored, /(?:^|\n)(?:```|~~~)[^\n]*\n[\s\S]*?\n(?:```|~~~)(?=\n|$)/g) !== plan.counts.fencedCodeBlocks
251
+ ) {
252
+ return {
253
+ ok: false,
254
+ missing: [],
255
+ duplicated: [],
256
+ extra: [],
257
+ reason: "fenced code block count mismatch",
258
+ }
259
+ }
260
+
261
+ if (countMatches(restored, /(?:https?:\/\/|wss?:\/\/|file:\/\/|mailto:)[^\s<>()]+/g) !== plan.counts.urls) {
262
+ return {
263
+ ok: false,
264
+ missing: [],
265
+ duplicated: [],
266
+ extra: [],
267
+ reason: "url count mismatch",
268
+ }
269
+ }
270
+
271
+ if (countPaths(restored) !== plan.counts.paths) {
272
+ return {
273
+ ok: false,
274
+ missing: [],
275
+ duplicated: [],
276
+ extra: [],
277
+ reason: "path count mismatch",
278
+ }
279
+ }
280
+
281
+ return {
282
+ ok: true,
283
+ text: restored,
284
+ }
285
+ }
@@ -0,0 +1,286 @@
1
+ import { createHash, randomUUID } from "node:crypto"
2
+ import { setTimeout as sleep } from "node:timers/promises"
3
+ import { generateText } from "ai"
4
+ import { createCredentialResolver } from "./auth"
5
+ import {
6
+ buildAuthUnavailableError,
7
+ type FetchLike,
8
+ normalizeReason,
9
+ PLUGIN_NAME,
10
+ type PluginClientLike,
11
+ type ProviderInfo,
12
+ parseTranslatorModel,
13
+ type ResolvedTranslateOptions,
14
+ } from "./constants"
15
+ import { buildSystemPrompt, buildUserPrompt } from "./prompts"
16
+ import { protectText, restoreProtectedText } from "./protect"
17
+
18
+ interface TranslatorDependencies {
19
+ generateTextImpl?: typeof generateText
20
+ sleep?: (ms: number) => Promise<void>
21
+ now?: () => number
22
+ credentialResolver?: ReturnType<typeof createCredentialResolver>
23
+ }
24
+
25
+ interface TranslateTextInput {
26
+ text: string
27
+ sourceLanguage: string
28
+ targetLanguage: string
29
+ direction: "inbound" | "outbound"
30
+ }
31
+
32
+ const providerFactoryCache = new Map<string, unknown>()
33
+
34
+ export function __resetTranslatorCachesForTest() {
35
+ providerFactoryCache.clear()
36
+ }
37
+
38
+ function getStatus(error: unknown): number | undefined {
39
+ if (!error || typeof error !== "object") return undefined
40
+ const record = error as Record<string, unknown>
41
+ if (typeof record.status === "number") return record.status
42
+ if (typeof record.statusCode === "number") return record.statusCode
43
+ const response = record.response
44
+ if (response && typeof response === "object") {
45
+ const status = (response as Record<string, unknown>).status
46
+ if (typeof status === "number") return status
47
+ }
48
+ return undefined
49
+ }
50
+
51
+ function getRetryAfterMs(error: unknown): number {
52
+ if (!error || typeof error !== "object") return 2000
53
+ const record = error as Record<string, unknown>
54
+ const response = record.response
55
+ if (!response || typeof response !== "object") return 2000
56
+ const headers = (response as { headers?: Headers }).headers
57
+ if (!(headers instanceof Headers)) return 2000
58
+ const retryAfter = headers.get("retry-after")
59
+ if (!retryAfter) return 2000
60
+ const seconds = Number(retryAfter)
61
+ if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000)
62
+ const date = Date.parse(retryAfter)
63
+ return Number.isFinite(date) ? Math.max(0, date - Date.now()) : 2000
64
+ }
65
+
66
+ function isRetryable(error: unknown): boolean {
67
+ const status = getStatus(error)
68
+ if (status === 429) return true
69
+ if (status !== undefined) return status >= 500
70
+ const message = normalizeReason(error).toLowerCase()
71
+ return (
72
+ message.includes("network") ||
73
+ message.includes("fetch") ||
74
+ message.includes("timeout") ||
75
+ message.includes("socket") ||
76
+ message.includes("econn")
77
+ )
78
+ }
79
+
80
+ async function withRetry<T>(task: () => Promise<T>, sleepImpl: (ms: number) => Promise<void>): Promise<T> {
81
+ let lastError: unknown
82
+ for (let attempt = 0; attempt < 3; attempt += 1) {
83
+ try {
84
+ return await task()
85
+ } catch (error) {
86
+ lastError = error
87
+ if (!isRetryable(error)) throw error
88
+ if (getStatus(error) === 429) {
89
+ if (attempt >= 1) throw error
90
+ await sleepImpl(getRetryAfterMs(error))
91
+ continue
92
+ }
93
+ if (attempt >= 2) throw error
94
+ await sleepImpl(attempt === 0 ? 500 : 1500)
95
+ }
96
+ }
97
+ throw lastError
98
+ }
99
+
100
+ async function loadFactory(providerID: string): Promise<unknown> {
101
+ const cached = providerFactoryCache.get(providerID)
102
+ if (cached) return cached
103
+
104
+ let factory: unknown
105
+ if (providerID === "anthropic") {
106
+ const mod = await import("@ai-sdk/anthropic")
107
+ factory = mod.createAnthropic ?? mod.anthropic
108
+ } else if (providerID === "openai") {
109
+ const mod = await import("@ai-sdk/openai")
110
+ factory = mod.createOpenAI ?? mod.openai
111
+ } else if (providerID === "google") {
112
+ const mod = await import("@ai-sdk/google")
113
+ factory = mod.createGoogleGenerativeAI ?? mod.google
114
+ } else if (providerID === "google-vertex") {
115
+ const mod = await import("@ai-sdk/google-vertex")
116
+ factory = mod.createVertex ?? mod.vertex
117
+ } else if (providerID === "amazon-bedrock") {
118
+ const mod = await import("@ai-sdk/amazon-bedrock")
119
+ factory = mod.createAmazonBedrock ?? mod.bedrock
120
+ } else if (providerID === "github-copilot") {
121
+ const mod = await import("@ai-sdk/openai-compatible")
122
+ factory = mod.createOpenAICompatible
123
+ } else {
124
+ throw new Error(`Unsupported translator provider "${providerID}"`)
125
+ }
126
+
127
+ if (typeof factory !== "function") {
128
+ throw new Error(`Unable to load provider factory for "${providerID}"`)
129
+ }
130
+
131
+ providerFactoryCache.set(providerID, factory)
132
+ return factory
133
+ }
134
+
135
+ function instantiateProvider(
136
+ factory: unknown,
137
+ providerID: string,
138
+ credentials: { apiKey?: string; fetch?: FetchLike },
139
+ ): unknown {
140
+ if (typeof factory !== "function") throw new Error(`Invalid provider factory for "${providerID}"`)
141
+
142
+ const config = {
143
+ ...(credentials.apiKey !== undefined ? { apiKey: credentials.apiKey } : {}),
144
+ ...(credentials.fetch ? { fetch: credentials.fetch } : {}),
145
+ }
146
+
147
+ if (providerID === "github-copilot") {
148
+ return (factory as (config: Record<string, unknown>) => unknown)({
149
+ ...config,
150
+ name: "github-copilot",
151
+ baseURL: "https://api.githubcopilot.com",
152
+ })
153
+ }
154
+
155
+ return (factory as (config: Record<string, unknown>) => unknown)(config)
156
+ }
157
+
158
+ function instantiateModel(provider: unknown, modelID: string): unknown {
159
+ if (typeof provider === "function") return provider(modelID)
160
+ if (provider && typeof provider === "object") {
161
+ const record = provider as Record<string, unknown>
162
+ if (typeof record.chatModel === "function") return (record.chatModel as (id: string) => unknown)(modelID)
163
+ if (typeof record.languageModel === "function") {
164
+ return (record.languageModel as (id: string) => unknown)(modelID)
165
+ }
166
+ }
167
+ throw new Error(`Unable to instantiate model "${modelID}"`)
168
+ }
169
+
170
+ function isAuthMessage(error: unknown): boolean {
171
+ if (!(error instanceof Error)) return false
172
+ return error.message.includes(":AUTH_UNAVAILABLE]") || error.message.includes(":OAUTH_REFRESH_FAILED]")
173
+ }
174
+
175
+ function modelProviderHint(providerID: string, provider?: ProviderInfo): Error {
176
+ return buildAuthUnavailableError(providerID, provider?.env[0] || "the provider's API key env var")
177
+ }
178
+
179
+ export function hashText(text: string): string {
180
+ return createHash("sha256").update(text, "utf8").digest("hex").slice(0, 16)
181
+ }
182
+
183
+ export function createSyntheticPartID(): string {
184
+ return `prt_${randomUUID().replaceAll("-", "")}`
185
+ }
186
+
187
+ export function createTranslator(
188
+ client: PluginClientLike,
189
+ options: ResolvedTranslateOptions,
190
+ deps: TranslatorDependencies = {},
191
+ ) {
192
+ const sleepImpl = deps.sleep ?? ((ms: number) => sleep(ms))
193
+ const now = deps.now ?? (() => Date.now())
194
+ const generateTextImpl = deps.generateTextImpl ?? generateText
195
+ const credentialResolver = deps.credentialResolver ?? createCredentialResolver(client, options)
196
+
197
+ async function translateText(input: TranslateTextInput): Promise<string> {
198
+ if (!input.text) return input.text
199
+ if (input.sourceLanguage === input.targetLanguage) return input.text
200
+
201
+ const startedAt = now()
202
+ const { providerID, modelID } = parseTranslatorModel(options.translatorModel)
203
+ const credentials = await credentialResolver.resolve(options.translatorModel)
204
+ const factory = await loadFactory(providerID)
205
+ const provider = instantiateProvider(factory, providerID, credentials)
206
+ const model = instantiateModel(provider, modelID)
207
+ const protectedText = protectText(input.text)
208
+
209
+ let missingPlaceholders: string[] | undefined
210
+ let lastError: unknown
211
+
212
+ for (let attempt = 0; attempt < 2; attempt += 1) {
213
+ try {
214
+ const translated = await withRetry(async () => {
215
+ try {
216
+ const result = (await generateTextImpl({
217
+ model: model as never,
218
+ system: buildSystemPrompt({
219
+ sourceLanguage: input.sourceLanguage,
220
+ targetLanguage: input.targetLanguage,
221
+ text: protectedText.text,
222
+ strictPlaceholderRetry: missingPlaceholders,
223
+ }),
224
+ temperature: 0,
225
+ prompt: buildUserPrompt({
226
+ sourceLanguage: input.sourceLanguage,
227
+ targetLanguage: input.targetLanguage,
228
+ text: protectedText.text,
229
+ }),
230
+ })) as { text: string }
231
+ return result.text
232
+ } catch (error) {
233
+ if (isAuthMessage(error)) throw error
234
+ if (credentials.mode === "default" && credentialResolver.isMissingCredentialError(error)) {
235
+ throw modelProviderHint(providerID, credentials.provider)
236
+ }
237
+ throw error
238
+ }
239
+ }, sleepImpl)
240
+
241
+ const restored = restoreProtectedText(protectedText, translated)
242
+ if (!restored.ok) {
243
+ missingPlaceholders =
244
+ restored.missing.length > 0 ? restored.missing : protectedText.placeholders.map((item) => item.token)
245
+ lastError = new Error(`Protection check failed: ${restored.reason}`)
246
+ continue
247
+ }
248
+
249
+ if (options.verbose) {
250
+ await client.app.log({
251
+ body: {
252
+ service: PLUGIN_NAME,
253
+ level: "info",
254
+ message: "translated",
255
+ extra: {
256
+ direction: input.direction,
257
+ chars_in: input.text.length,
258
+ chars_out: restored.text.length,
259
+ ms: now() - startedAt,
260
+ cached: false,
261
+ model: options.translatorModel,
262
+ },
263
+ },
264
+ })
265
+ }
266
+
267
+ return restored.text
268
+ } catch (error) {
269
+ if (isAuthMessage(error)) throw error
270
+ lastError = error
271
+ }
272
+ }
273
+
274
+ if (lastError instanceof Error && lastError.message.includes(":AUTH_UNAVAILABLE]")) {
275
+ throw lastError
276
+ }
277
+ if (lastError instanceof Error && lastError.message.includes(":OAUTH_REFRESH_FAILED]")) {
278
+ throw lastError
279
+ }
280
+ throw new Error(normalizeReason(lastError))
281
+ }
282
+
283
+ return {
284
+ translateText,
285
+ }
286
+ }