opencode-translate 0.1.1 → 0.1.3

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.
Files changed (40) hide show
  1. package/README.md +1 -1
  2. package/package.json +6 -3
  3. package/src/activation/chat-message.ts +164 -0
  4. package/src/activation/index.ts +38 -0
  5. package/src/activation/logging.ts +11 -0
  6. package/src/activation/messages-transform.ts +38 -0
  7. package/src/activation/metadata.ts +42 -0
  8. package/src/activation/parts.ts +53 -0
  9. package/src/activation/question-hooks.ts +95 -0
  10. package/src/activation/state.ts +98 -0
  11. package/src/activation/text-complete.ts +51 -0
  12. package/src/activation/trigger.ts +57 -0
  13. package/src/activation/types.ts +41 -0
  14. package/src/activation.ts +1 -607
  15. package/src/anthropic-oauth.ts +3 -3
  16. package/src/auth/codex-request.ts +108 -0
  17. package/src/auth/codex-response.ts +78 -0
  18. package/src/auth/codex-shared.ts +3 -0
  19. package/src/auth/headers.ts +18 -0
  20. package/src/auth/index.ts +153 -0
  21. package/src/auth/oauth-fetch.ts +100 -0
  22. package/src/auth/refresh.ts +102 -0
  23. package/src/auth/retry.ts +70 -0
  24. package/src/auth/store.ts +45 -0
  25. package/src/auth/types.ts +27 -0
  26. package/src/auth.ts +1 -725
  27. package/src/constants/errors.ts +24 -0
  28. package/src/constants/guards.ts +34 -0
  29. package/src/constants/options.ts +37 -0
  30. package/src/constants/plugin.ts +10 -0
  31. package/src/constants/types.ts +147 -0
  32. package/src/constants.ts +5 -261
  33. package/src/labels.ts +0 -2
  34. package/src/question-tool.ts +45 -22
  35. package/src/translator/index.ts +125 -0
  36. package/src/translator/part-id.ts +43 -0
  37. package/src/translator/provider.ts +81 -0
  38. package/src/translator/retry.ts +62 -0
  39. package/src/translator/types.ts +17 -0
  40. package/src/translator.ts +1 -326
@@ -0,0 +1,125 @@
1
+ import { setTimeout as sleep } from "node:timers/promises"
2
+ import { generateText } from "ai"
3
+ import { createCredentialResolver } from "../auth"
4
+ import {
5
+ buildAuthUnavailableError,
6
+ PLUGIN_NAME,
7
+ type PluginClientLike,
8
+ type ProviderInfo,
9
+ parseTranslatorModel,
10
+ type ResolvedTranslateOptions,
11
+ } from "../constants"
12
+ import { buildSystemPrompt, buildUserPrompt, unwrapEchoedTextEnvelope } from "../prompts"
13
+ import { __resetSyntheticPartIDForTest } from "./part-id"
14
+ import {
15
+ __resetProviderFactoryCacheForTest,
16
+ instantiateModel,
17
+ instantiateProvider,
18
+ loadFactory,
19
+ supportsTemperature,
20
+ } from "./provider"
21
+ import { withRetry } from "./retry"
22
+ import type { TranslateTextInput, TranslatorDependencies } from "./types"
23
+
24
+ const DEFAULT_TRANSLATE_TIMEOUT_MS = 180_000
25
+
26
+ function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
27
+ return new Promise<T>((resolve, reject) => {
28
+ const timer = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs)
29
+ promise.then(
30
+ (value) => {
31
+ clearTimeout(timer)
32
+ resolve(value)
33
+ },
34
+ (error) => {
35
+ clearTimeout(timer)
36
+ reject(error)
37
+ },
38
+ )
39
+ })
40
+ }
41
+
42
+ function isAuthMessage(error: unknown): boolean {
43
+ if (!(error instanceof Error)) return false
44
+ return error.message.includes(":AUTH_UNAVAILABLE]") || error.message.includes(":OAUTH_REFRESH_FAILED]")
45
+ }
46
+
47
+ function modelProviderHint(providerID: string, provider?: ProviderInfo): Error {
48
+ return buildAuthUnavailableError(providerID, provider?.env[0] || "the provider's API key env var")
49
+ }
50
+
51
+ export function __resetTranslatorCachesForTest() {
52
+ __resetProviderFactoryCacheForTest()
53
+ __resetSyntheticPartIDForTest()
54
+ }
55
+
56
+ export function createTranslator(
57
+ client: PluginClientLike,
58
+ options: ResolvedTranslateOptions,
59
+ deps: TranslatorDependencies = {},
60
+ ) {
61
+ const sleepImpl = deps.sleep ?? ((ms: number) => sleep(ms))
62
+ const now = deps.now ?? (() => Date.now())
63
+ const generateTextImpl = deps.generateTextImpl ?? generateText
64
+ const credentialResolver = deps.credentialResolver ?? createCredentialResolver(client, options)
65
+ const timeoutMs = deps.timeoutMs ?? DEFAULT_TRANSLATE_TIMEOUT_MS
66
+
67
+ async function translateText(input: TranslateTextInput): Promise<string> {
68
+ if (!input.text) return input.text
69
+ if (input.sourceLanguage === input.targetLanguage) return input.text
70
+
71
+ const startedAt = now()
72
+ const { providerID, modelID } = parseTranslatorModel(options.translatorModel)
73
+ const credentials = await credentialResolver.resolve(options.translatorModel)
74
+ const factory = await loadFactory(providerID)
75
+ const provider = instantiateProvider(factory, providerID, credentials)
76
+ const model = instantiateModel(provider, modelID)
77
+
78
+ const rawTranslated = await withRetry(async () => {
79
+ try {
80
+ const result = (await withTimeout(
81
+ generateTextImpl({
82
+ model: model as never,
83
+ system: buildSystemPrompt(input),
84
+ ...(supportsTemperature(providerID, modelID) ? { temperature: 0 } : {}),
85
+ prompt: buildUserPrompt(input),
86
+ }) as Promise<{ text: string }>,
87
+ timeoutMs,
88
+ "Translator generateText",
89
+ )) as { text: string }
90
+ return result.text
91
+ } catch (error) {
92
+ if (isAuthMessage(error)) throw error
93
+ if (credentials.mode === "default" && credentialResolver.isMissingCredentialError(error)) {
94
+ throw modelProviderHint(providerID, credentials.provider)
95
+ }
96
+ throw error
97
+ }
98
+ }, sleepImpl)
99
+ const translated = unwrapEchoedTextEnvelope(rawTranslated)
100
+
101
+ if (options.verbose) {
102
+ await client.app.log({
103
+ body: {
104
+ service: PLUGIN_NAME,
105
+ level: "info",
106
+ message: "translated",
107
+ extra: {
108
+ direction: input.direction,
109
+ chars_in: input.text.length,
110
+ chars_out: translated.length,
111
+ ms: now() - startedAt,
112
+ cached: false,
113
+ model: options.translatorModel,
114
+ },
115
+ },
116
+ })
117
+ }
118
+
119
+ return translated
120
+ }
121
+
122
+ return { translateText }
123
+ }
124
+
125
+ export { __resetSyntheticPartIDForTest, createSyntheticPartID, hashText } from "./part-id"
@@ -0,0 +1,43 @@
1
+ import { createHash, randomBytes } from "node:crypto"
2
+
3
+ const PART_ID_LENGTH = 26
4
+ const PART_ID_PREFIX = "prt"
5
+ const BASE62_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
6
+
7
+ let partLastTimestamp = 0
8
+ let partCounter = 0
9
+
10
+ export function __resetSyntheticPartIDForTest() {
11
+ partLastTimestamp = 0
12
+ partCounter = 0
13
+ }
14
+
15
+ function randomBase62(length: number): string {
16
+ const bytes = randomBytes(length)
17
+ let result = ""
18
+ for (let index = 0; index < length; index += 1) {
19
+ result += BASE62_CHARS[bytes[index] % BASE62_CHARS.length]
20
+ }
21
+ return result
22
+ }
23
+
24
+ export function hashText(text: string): string {
25
+ return createHash("sha256").update(text, "utf8").digest("hex").slice(0, 16)
26
+ }
27
+
28
+ export function createSyntheticPartID(): string {
29
+ const currentTimestamp = Date.now()
30
+ if (currentTimestamp !== partLastTimestamp) {
31
+ partLastTimestamp = currentTimestamp
32
+ partCounter = 0
33
+ }
34
+ partCounter += 1
35
+
36
+ const encoded = BigInt(currentTimestamp) * BigInt(0x1000) + BigInt(partCounter)
37
+ const timeBytes = Buffer.alloc(6)
38
+ for (let index = 0; index < 6; index += 1) {
39
+ timeBytes[index] = Number((encoded >> BigInt(40 - 8 * index)) & BigInt(0xff))
40
+ }
41
+
42
+ return `${PART_ID_PREFIX}_${timeBytes.toString("hex")}${randomBase62(PART_ID_LENGTH - 12)}`
43
+ }
@@ -0,0 +1,81 @@
1
+ import type { FetchLike } from "../constants"
2
+
3
+ const providerFactoryCache = new Map<string, unknown>()
4
+
5
+ export function __resetProviderFactoryCacheForTest() {
6
+ providerFactoryCache.clear()
7
+ }
8
+
9
+ export async function loadFactory(providerID: string): Promise<unknown> {
10
+ const cached = providerFactoryCache.get(providerID)
11
+ if (cached) return cached
12
+
13
+ let factory: unknown
14
+ if (providerID === "anthropic") {
15
+ const mod = await import("@ai-sdk/anthropic")
16
+ factory = mod.createAnthropic ?? mod.anthropic
17
+ } else if (providerID === "openai") {
18
+ const mod = await import("@ai-sdk/openai")
19
+ factory = mod.createOpenAI ?? mod.openai
20
+ } else if (providerID === "google") {
21
+ const mod = await import("@ai-sdk/google")
22
+ factory = mod.createGoogleGenerativeAI ?? mod.google
23
+ } else if (providerID === "google-vertex") {
24
+ const mod = await import("@ai-sdk/google-vertex")
25
+ factory = mod.createVertex ?? mod.vertex
26
+ } else if (providerID === "amazon-bedrock") {
27
+ const mod = await import("@ai-sdk/amazon-bedrock")
28
+ factory = mod.createAmazonBedrock ?? mod.bedrock
29
+ } else if (providerID === "github-copilot") {
30
+ const mod = await import("@ai-sdk/openai-compatible")
31
+ factory = mod.createOpenAICompatible
32
+ } else {
33
+ throw new Error(`Unsupported translator provider "${providerID}"`)
34
+ }
35
+
36
+ if (typeof factory !== "function") {
37
+ throw new Error(`Unable to load provider factory for "${providerID}"`)
38
+ }
39
+
40
+ providerFactoryCache.set(providerID, factory)
41
+ return factory
42
+ }
43
+
44
+ export function instantiateProvider(
45
+ factory: unknown,
46
+ providerID: string,
47
+ credentials: { apiKey?: string; fetch?: FetchLike },
48
+ ): unknown {
49
+ if (typeof factory !== "function") throw new Error(`Invalid provider factory for "${providerID}"`)
50
+
51
+ const config = {
52
+ ...(credentials.apiKey !== undefined ? { apiKey: credentials.apiKey } : {}),
53
+ ...(credentials.fetch ? { fetch: credentials.fetch } : {}),
54
+ }
55
+
56
+ if (providerID === "github-copilot") {
57
+ return (factory as (config: Record<string, unknown>) => unknown)({
58
+ ...config,
59
+ name: "github-copilot",
60
+ baseURL: "https://api.githubcopilot.com",
61
+ })
62
+ }
63
+
64
+ return (factory as (config: Record<string, unknown>) => unknown)(config)
65
+ }
66
+
67
+ export function instantiateModel(provider: unknown, modelID: string): unknown {
68
+ if (typeof provider === "function") return provider(modelID)
69
+ if (provider && typeof provider === "object") {
70
+ const record = provider as Record<string, unknown>
71
+ if (typeof record.chatModel === "function") return (record.chatModel as (id: string) => unknown)(modelID)
72
+ if (typeof record.languageModel === "function") return (record.languageModel as (id: string) => unknown)(modelID)
73
+ }
74
+ throw new Error(`Unable to instantiate model "${modelID}"`)
75
+ }
76
+
77
+ export function supportsTemperature(providerID: string, modelID: string): boolean {
78
+ if (providerID !== "openai") return true
79
+ if (modelID.startsWith("o1") || modelID.startsWith("o3") || modelID.startsWith("o4-mini")) return false
80
+ return !(modelID.startsWith("gpt-5") && !modelID.startsWith("gpt-5-chat"))
81
+ }
@@ -0,0 +1,62 @@
1
+ import { normalizeReason } from "../constants"
2
+
3
+ function getStatus(error: unknown): number | undefined {
4
+ if (!error || typeof error !== "object") return undefined
5
+ const record = error as Record<string, unknown>
6
+ if (typeof record.status === "number") return record.status
7
+ if (typeof record.statusCode === "number") return record.statusCode
8
+ const response = record.response
9
+ if (response && typeof response === "object") {
10
+ const status = (response as Record<string, unknown>).status
11
+ if (typeof status === "number") return status
12
+ }
13
+ return undefined
14
+ }
15
+
16
+ function getRetryAfterMs(error: unknown): number {
17
+ if (!error || typeof error !== "object") return 2000
18
+ const response = (error as Record<string, unknown>).response
19
+ if (!response || typeof response !== "object") return 2000
20
+ const headers = (response as { headers?: Headers }).headers
21
+ if (!(headers instanceof Headers)) return 2000
22
+ const retryAfter = headers.get("retry-after")
23
+ if (!retryAfter) return 2000
24
+ const seconds = Number(retryAfter)
25
+ if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000)
26
+ const date = Date.parse(retryAfter)
27
+ return Number.isFinite(date) ? Math.max(0, date - Date.now()) : 2000
28
+ }
29
+
30
+ function isRetryable(error: unknown): boolean {
31
+ const status = getStatus(error)
32
+ if (status === 429) return true
33
+ if (status !== undefined) return status >= 500
34
+ const message = normalizeReason(error).toLowerCase()
35
+ return (
36
+ message.includes("network") ||
37
+ message.includes("fetch") ||
38
+ message.includes("timeout") ||
39
+ message.includes("socket") ||
40
+ message.includes("econn")
41
+ )
42
+ }
43
+
44
+ export async function withRetry<T>(task: () => Promise<T>, sleepImpl: (ms: number) => Promise<void>): Promise<T> {
45
+ let lastError: unknown
46
+ for (let attempt = 0; attempt < 3; attempt += 1) {
47
+ try {
48
+ return await task()
49
+ } catch (error) {
50
+ lastError = error
51
+ if (!isRetryable(error)) throw error
52
+ if (getStatus(error) === 429) {
53
+ if (attempt >= 1) throw error
54
+ await sleepImpl(getRetryAfterMs(error))
55
+ continue
56
+ }
57
+ if (attempt >= 2) throw error
58
+ await sleepImpl(attempt === 0 ? 500 : 1500)
59
+ }
60
+ }
61
+ throw lastError
62
+ }
@@ -0,0 +1,17 @@
1
+ import type { generateText } from "ai"
2
+ import type { createCredentialResolver } from "../auth"
3
+
4
+ export interface TranslatorDependencies {
5
+ generateTextImpl?: typeof generateText
6
+ sleep?: (ms: number) => Promise<void>
7
+ now?: () => number
8
+ credentialResolver?: ReturnType<typeof createCredentialResolver>
9
+ timeoutMs?: number
10
+ }
11
+
12
+ export interface TranslateTextInput {
13
+ text: string
14
+ sourceLanguage: string
15
+ targetLanguage: string
16
+ direction: "inbound" | "outbound"
17
+ }
package/src/translator.ts CHANGED
@@ -1,326 +1 @@
1
- import { createHash, randomBytes } 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, unwrapEchoedTextEnvelope } from "./prompts"
16
-
17
- interface TranslatorDependencies {
18
- generateTextImpl?: typeof generateText
19
- sleep?: (ms: number) => Promise<void>
20
- now?: () => number
21
- credentialResolver?: ReturnType<typeof createCredentialResolver>
22
- timeoutMs?: number
23
- }
24
-
25
- interface TranslateTextInput {
26
- text: string
27
- sourceLanguage: string
28
- targetLanguage: string
29
- direction: "inbound" | "outbound"
30
- }
31
-
32
- // Hard timeout for a single generateText call. Without this, a stalled
33
- // provider request can block the chat.message hook indefinitely. Long
34
- // assistant responses can require well over a minute to translate, so
35
- // the budget here is generous; retries will still bound the worst case.
36
- const DEFAULT_TRANSLATE_TIMEOUT_MS = 180_000
37
-
38
- function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
39
- return new Promise<T>((resolve, reject) => {
40
- const timer = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs)
41
- promise.then(
42
- (value) => {
43
- clearTimeout(timer)
44
- resolve(value)
45
- },
46
- (error) => {
47
- clearTimeout(timer)
48
- reject(error)
49
- },
50
- )
51
- })
52
- }
53
-
54
- const providerFactoryCache = new Map<string, unknown>()
55
-
56
- const PART_ID_LENGTH = 26
57
- const PART_ID_PREFIX = "prt"
58
- const BASE62_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
59
- let partLastTimestamp = 0
60
- let partCounter = 0
61
-
62
- export function __resetTranslatorCachesForTest() {
63
- providerFactoryCache.clear()
64
- __resetSyntheticPartIDForTest()
65
- }
66
-
67
- export function __resetSyntheticPartIDForTest() {
68
- partLastTimestamp = 0
69
- partCounter = 0
70
- }
71
-
72
- function randomBase62(length: number): string {
73
- const bytes = randomBytes(length)
74
- let result = ""
75
- for (let index = 0; index < length; index += 1) {
76
- result += BASE62_CHARS[bytes[index] % BASE62_CHARS.length]
77
- }
78
- return result
79
- }
80
-
81
- function getStatus(error: unknown): number | undefined {
82
- if (!error || typeof error !== "object") return undefined
83
- const record = error as Record<string, unknown>
84
- if (typeof record.status === "number") return record.status
85
- if (typeof record.statusCode === "number") return record.statusCode
86
- const response = record.response
87
- if (response && typeof response === "object") {
88
- const status = (response as Record<string, unknown>).status
89
- if (typeof status === "number") return status
90
- }
91
- return undefined
92
- }
93
-
94
- function getRetryAfterMs(error: unknown): number {
95
- if (!error || typeof error !== "object") return 2000
96
- const record = error as Record<string, unknown>
97
- const response = record.response
98
- if (!response || typeof response !== "object") return 2000
99
- const headers = (response as { headers?: Headers }).headers
100
- if (!(headers instanceof Headers)) return 2000
101
- const retryAfter = headers.get("retry-after")
102
- if (!retryAfter) return 2000
103
- const seconds = Number(retryAfter)
104
- if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000)
105
- const date = Date.parse(retryAfter)
106
- return Number.isFinite(date) ? Math.max(0, date - Date.now()) : 2000
107
- }
108
-
109
- function isRetryable(error: unknown): boolean {
110
- const status = getStatus(error)
111
- if (status === 429) return true
112
- if (status !== undefined) return status >= 500
113
- const message = normalizeReason(error).toLowerCase()
114
- return (
115
- message.includes("network") ||
116
- message.includes("fetch") ||
117
- message.includes("timeout") ||
118
- message.includes("socket") ||
119
- message.includes("econn")
120
- )
121
- }
122
-
123
- async function withRetry<T>(task: () => Promise<T>, sleepImpl: (ms: number) => Promise<void>): Promise<T> {
124
- let lastError: unknown
125
- for (let attempt = 0; attempt < 3; attempt += 1) {
126
- try {
127
- return await task()
128
- } catch (error) {
129
- lastError = error
130
- if (!isRetryable(error)) throw error
131
- if (getStatus(error) === 429) {
132
- if (attempt >= 1) throw error
133
- await sleepImpl(getRetryAfterMs(error))
134
- continue
135
- }
136
- if (attempt >= 2) throw error
137
- await sleepImpl(attempt === 0 ? 500 : 1500)
138
- }
139
- }
140
- throw lastError
141
- }
142
-
143
- async function loadFactory(providerID: string): Promise<unknown> {
144
- const cached = providerFactoryCache.get(providerID)
145
- if (cached) return cached
146
-
147
- let factory: unknown
148
- if (providerID === "anthropic") {
149
- const mod = await import("@ai-sdk/anthropic")
150
- factory = mod.createAnthropic ?? mod.anthropic
151
- } else if (providerID === "openai") {
152
- const mod = await import("@ai-sdk/openai")
153
- factory = mod.createOpenAI ?? mod.openai
154
- } else if (providerID === "google") {
155
- const mod = await import("@ai-sdk/google")
156
- factory = mod.createGoogleGenerativeAI ?? mod.google
157
- } else if (providerID === "google-vertex") {
158
- const mod = await import("@ai-sdk/google-vertex")
159
- factory = mod.createVertex ?? mod.vertex
160
- } else if (providerID === "amazon-bedrock") {
161
- const mod = await import("@ai-sdk/amazon-bedrock")
162
- factory = mod.createAmazonBedrock ?? mod.bedrock
163
- } else if (providerID === "github-copilot") {
164
- const mod = await import("@ai-sdk/openai-compatible")
165
- factory = mod.createOpenAICompatible
166
- } else {
167
- throw new Error(`Unsupported translator provider "${providerID}"`)
168
- }
169
-
170
- if (typeof factory !== "function") {
171
- throw new Error(`Unable to load provider factory for "${providerID}"`)
172
- }
173
-
174
- providerFactoryCache.set(providerID, factory)
175
- return factory
176
- }
177
-
178
- function instantiateProvider(
179
- factory: unknown,
180
- providerID: string,
181
- credentials: { apiKey?: string; fetch?: FetchLike },
182
- ): unknown {
183
- if (typeof factory !== "function") throw new Error(`Invalid provider factory for "${providerID}"`)
184
-
185
- const config = {
186
- ...(credentials.apiKey !== undefined ? { apiKey: credentials.apiKey } : {}),
187
- ...(credentials.fetch ? { fetch: credentials.fetch } : {}),
188
- }
189
-
190
- if (providerID === "github-copilot") {
191
- return (factory as (config: Record<string, unknown>) => unknown)({
192
- ...config,
193
- name: "github-copilot",
194
- baseURL: "https://api.githubcopilot.com",
195
- })
196
- }
197
-
198
- return (factory as (config: Record<string, unknown>) => unknown)(config)
199
- }
200
-
201
- function instantiateModel(provider: unknown, modelID: string): unknown {
202
- if (typeof provider === "function") return provider(modelID)
203
- if (provider && typeof provider === "object") {
204
- const record = provider as Record<string, unknown>
205
- if (typeof record.chatModel === "function") return (record.chatModel as (id: string) => unknown)(modelID)
206
- if (typeof record.languageModel === "function") {
207
- return (record.languageModel as (id: string) => unknown)(modelID)
208
- }
209
- }
210
- throw new Error(`Unable to instantiate model "${modelID}"`)
211
- }
212
-
213
- function supportsTemperature(providerID: string, modelID: string): boolean {
214
- if (providerID !== "openai") return true
215
- if (modelID.startsWith("o1") || modelID.startsWith("o3") || modelID.startsWith("o4-mini")) return false
216
- return !(modelID.startsWith("gpt-5") && !modelID.startsWith("gpt-5-chat"))
217
- }
218
-
219
- function isAuthMessage(error: unknown): boolean {
220
- if (!(error instanceof Error)) return false
221
- return error.message.includes(":AUTH_UNAVAILABLE]") || error.message.includes(":OAUTH_REFRESH_FAILED]")
222
- }
223
-
224
- function modelProviderHint(providerID: string, provider?: ProviderInfo): Error {
225
- return buildAuthUnavailableError(providerID, provider?.env[0] || "the provider's API key env var")
226
- }
227
-
228
- export function hashText(text: string): string {
229
- return createHash("sha256").update(text, "utf8").digest("hex").slice(0, 16)
230
- }
231
-
232
- export function createSyntheticPartID(): string {
233
- const currentTimestamp = Date.now()
234
- if (currentTimestamp !== partLastTimestamp) {
235
- partLastTimestamp = currentTimestamp
236
- partCounter = 0
237
- }
238
- partCounter += 1
239
-
240
- const encoded = BigInt(currentTimestamp) * BigInt(0x1000) + BigInt(partCounter)
241
- const timeBytes = Buffer.alloc(6)
242
- for (let index = 0; index < 6; index += 1) {
243
- timeBytes[index] = Number((encoded >> BigInt(40 - 8 * index)) & BigInt(0xff))
244
- }
245
-
246
- return `${PART_ID_PREFIX}_${timeBytes.toString("hex")}${randomBase62(PART_ID_LENGTH - 12)}`
247
- }
248
-
249
- export function createTranslator(
250
- client: PluginClientLike,
251
- options: ResolvedTranslateOptions,
252
- deps: TranslatorDependencies = {},
253
- ) {
254
- const sleepImpl = deps.sleep ?? ((ms: number) => sleep(ms))
255
- const now = deps.now ?? (() => Date.now())
256
- const generateTextImpl = deps.generateTextImpl ?? generateText
257
- const credentialResolver = deps.credentialResolver ?? createCredentialResolver(client, options)
258
- const timeoutMs = deps.timeoutMs ?? DEFAULT_TRANSLATE_TIMEOUT_MS
259
-
260
- async function translateText(input: TranslateTextInput): Promise<string> {
261
- if (!input.text) return input.text
262
- if (input.sourceLanguage === input.targetLanguage) return input.text
263
-
264
- const startedAt = now()
265
- const { providerID, modelID } = parseTranslatorModel(options.translatorModel)
266
- const credentials = await credentialResolver.resolve(options.translatorModel)
267
- const factory = await loadFactory(providerID)
268
- const provider = instantiateProvider(factory, providerID, credentials)
269
- const model = instantiateModel(provider, modelID)
270
-
271
- const rawTranslated = await withRetry(async () => {
272
- try {
273
- const result = (await withTimeout(
274
- generateTextImpl({
275
- model: model as never,
276
- system: buildSystemPrompt({
277
- sourceLanguage: input.sourceLanguage,
278
- targetLanguage: input.targetLanguage,
279
- text: input.text,
280
- }),
281
- ...(supportsTemperature(providerID, modelID) ? { temperature: 0 } : {}),
282
- prompt: buildUserPrompt({
283
- sourceLanguage: input.sourceLanguage,
284
- targetLanguage: input.targetLanguage,
285
- text: input.text,
286
- }),
287
- }) as Promise<{ text: string }>,
288
- timeoutMs,
289
- "Translator generateText",
290
- )) as { text: string }
291
- return result.text
292
- } catch (error) {
293
- if (isAuthMessage(error)) throw error
294
- if (credentials.mode === "default" && credentialResolver.isMissingCredentialError(error)) {
295
- throw modelProviderHint(providerID, credentials.provider)
296
- }
297
- throw error
298
- }
299
- }, sleepImpl)
300
- const translated = unwrapEchoedTextEnvelope(rawTranslated)
301
-
302
- if (options.verbose) {
303
- await client.app.log({
304
- body: {
305
- service: PLUGIN_NAME,
306
- level: "info",
307
- message: "translated",
308
- extra: {
309
- direction: input.direction,
310
- chars_in: input.text.length,
311
- chars_out: translated.length,
312
- ms: now() - startedAt,
313
- cached: false,
314
- model: options.translatorModel,
315
- },
316
- },
317
- })
318
- }
319
-
320
- return translated
321
- }
322
-
323
- return {
324
- translateText,
325
- }
326
- }
1
+ export * from "./translator/index"