opencode-translate 0.1.2 → 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.
@@ -0,0 +1,24 @@
1
+ import { PLUGIN_NAME } from "./plugin"
2
+
3
+ export function normalizeReason(error: unknown): string {
4
+ const raw = error instanceof Error ? error.message : String(error)
5
+ return raw.split(/\r?\n/, 1)[0].trim().slice(0, 200)
6
+ }
7
+
8
+ export function buildInboundTranslationError(sourceLanguage: string, reason: string): Error {
9
+ return new Error(
10
+ `[${PLUGIN_NAME}:INBOUND_TRANSLATION_FAILED] Failed to translate user message from ${sourceLanguage} to en: ${reason}`,
11
+ )
12
+ }
13
+
14
+ export function buildAuthUnavailableError(providerID: string, envVar: string): Error {
15
+ return new Error(
16
+ `[${PLUGIN_NAME}:AUTH_UNAVAILABLE] No credential found for provider "${providerID}". Set ${envVar} in the environment, run "opencode auth login ${providerID}", or set options.apiKey in opencode.json.`,
17
+ )
18
+ }
19
+
20
+ export function buildOAuthRefreshError(providerID: string, reason: string): Error {
21
+ return new Error(
22
+ `[${PLUGIN_NAME}:OAUTH_REFRESH_FAILED] Failed to refresh OAuth token for provider "${providerID}": ${reason}. Re-authenticate with "opencode auth login ${providerID}".`,
23
+ )
24
+ }
@@ -0,0 +1,34 @@
1
+ import { LLM_LANGUAGE, NONCE_PATTERN } from "./plugin"
2
+ import type { SDKResponseLike, TextPartLike, TranslateState } from "./types"
3
+
4
+ function isNonEmptyString(value: unknown): value is string {
5
+ return typeof value === "string" && value.length > 0
6
+ }
7
+
8
+ export function unwrapData<T>(value: T | SDKResponseLike<T>): T {
9
+ if (value && typeof value === "object" && "data" in value && (value as SDKResponseLike<T>).data !== undefined) {
10
+ return (value as SDKResponseLike<T>).data as T
11
+ }
12
+ return value as T
13
+ }
14
+
15
+ export function isTranslateStateRecord(value: unknown): value is TranslateState {
16
+ if (!value || typeof value !== "object") return false
17
+ const record = value as Record<string, unknown>
18
+ return (
19
+ record.translate_enabled === true &&
20
+ record.translate_llm_lang === LLM_LANGUAGE &&
21
+ isNonEmptyString(record.translate_source_lang) &&
22
+ isNonEmptyString(record.translate_display_lang) &&
23
+ isNonEmptyString(record.translate_nonce) &&
24
+ NONCE_PATTERN.test(record.translate_nonce)
25
+ )
26
+ }
27
+
28
+ export function isTextPart(part: TextPartLike): part is TextPartLike & { text: string } {
29
+ return part.type === "text" && typeof part.text === "string"
30
+ }
31
+
32
+ export function isUserAuthoredTextPart(part: TextPartLike): part is TextPartLike & { text: string } {
33
+ return isTextPart(part) && part.synthetic !== true && part.ignored !== true
34
+ }
@@ -0,0 +1,37 @@
1
+ import { AUTH_ENV_FALLBACK, DEFAULT_TRANSLATOR_MODEL, DEFAULT_TRIGGER_KEYWORDS } from "./plugin"
2
+ import type { ProviderInfo, ResolvedTranslateOptions } from "./types"
3
+
4
+ export function resolveOptions(options: Record<string, unknown>): ResolvedTranslateOptions {
5
+ const triggerKeywords = Array.isArray(options.triggerKeywords)
6
+ ? options.triggerKeywords.filter((value): value is string => typeof value === "string" && value.length > 0)
7
+ : DEFAULT_TRIGGER_KEYWORDS
8
+
9
+ return {
10
+ translatorModel:
11
+ typeof options.translatorModel === "string" && options.translatorModel.includes("/")
12
+ ? options.translatorModel
13
+ : DEFAULT_TRANSLATOR_MODEL,
14
+ triggerKeywords: triggerKeywords.length > 0 ? triggerKeywords : [...DEFAULT_TRIGGER_KEYWORDS],
15
+ sourceLanguage:
16
+ typeof options.sourceLanguage === "string" && options.sourceLanguage.trim() ? options.sourceLanguage : "en",
17
+ displayLanguage:
18
+ typeof options.displayLanguage === "string" && options.displayLanguage.trim() ? options.displayLanguage : "en",
19
+ apiKey: typeof options.apiKey === "string" && options.apiKey.length > 0 ? options.apiKey : undefined,
20
+ verbose: options.verbose === true,
21
+ }
22
+ }
23
+
24
+ export function getEnvVarHint(provider: ProviderInfo | undefined): string {
25
+ return provider?.env[0] || AUTH_ENV_FALLBACK
26
+ }
27
+
28
+ export function parseTranslatorModel(model: string): { providerID: string; modelID: string } {
29
+ const slash = model.indexOf("/")
30
+ if (slash < 1 || slash === model.length - 1) {
31
+ return { providerID: "anthropic", modelID: model }
32
+ }
33
+ return {
34
+ providerID: model.slice(0, slash),
35
+ modelID: model.slice(slash + 1),
36
+ }
37
+ }
@@ -0,0 +1,10 @@
1
+ export const PLUGIN_NAME = "opencode-translate"
2
+ export const SPEC_VERSION = 1
3
+ export const LLM_LANGUAGE = "en"
4
+ export const DEFAULT_TRANSLATOR_MODEL = "anthropic/claude-haiku-4-5"
5
+ export const DEFAULT_TRIGGER_KEYWORDS = ["$en"]
6
+ export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key"
7
+ export const NONCE_PATTERN = /^[0-9a-f]{32}$/
8
+ export const FAILURE_NOTICE = "_Translation unavailable for this segment._"
9
+ export const AUTH_ENV_FALLBACK = "the provider's API key env var"
10
+ export const USER_AGENT = `${PLUGIN_NAME}/0.0.0`
@@ -0,0 +1,147 @@
1
+ import type { LLM_LANGUAGE } from "./plugin"
2
+
3
+ export type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
4
+
5
+ type ProviderSource = "env" | "config" | "custom" | "api"
6
+
7
+ export interface ResolvedTranslateOptions {
8
+ translatorModel: string
9
+ triggerKeywords: string[]
10
+ sourceLanguage: string
11
+ displayLanguage: string
12
+ apiKey?: string
13
+ verbose: boolean
14
+ }
15
+
16
+ export interface TranslateState {
17
+ translate_enabled: true
18
+ translate_source_lang: string
19
+ translate_display_lang: string
20
+ translate_llm_lang: typeof LLM_LANGUAGE
21
+ translate_nonce: string
22
+ }
23
+
24
+ export interface StoredTextMetadata extends Record<string, unknown> {
25
+ translate_enabled?: boolean
26
+ translate_source_lang?: string
27
+ translate_display_lang?: string
28
+ translate_llm_lang?: string
29
+ translate_nonce?: string
30
+ translate_role?: string
31
+ translate_spec_version?: number
32
+ translate_source_hash?: string
33
+ translate_en?: string
34
+ translate_part_index?: number
35
+ compaction_continue?: boolean
36
+ }
37
+
38
+ interface SessionLike {
39
+ id: string
40
+ parentID?: string | null
41
+ }
42
+
43
+ interface MessageLike {
44
+ id: string
45
+ sessionID: string
46
+ role: string
47
+ }
48
+
49
+ export interface TextPartLike {
50
+ id: string
51
+ sessionID: string
52
+ messageID: string
53
+ type: string
54
+ text?: string
55
+ synthetic?: boolean
56
+ ignored?: boolean
57
+ metadata?: Record<string, unknown>
58
+ }
59
+
60
+ export interface MessageWithPartsLike {
61
+ info: MessageLike
62
+ parts: TextPartLike[]
63
+ }
64
+
65
+ export interface ProviderInfo {
66
+ id: string
67
+ source: ProviderSource
68
+ env: string[]
69
+ key?: string
70
+ options?: Record<string, unknown>
71
+ models?: Record<string, unknown>
72
+ }
73
+
74
+ interface ProviderListResponseLike {
75
+ all: ProviderInfo[]
76
+ }
77
+
78
+ interface ApiAuthInfo {
79
+ type: "api"
80
+ key: string
81
+ metadata?: Record<string, string>
82
+ }
83
+
84
+ export interface OAuthInfo {
85
+ type: "oauth"
86
+ refresh: string
87
+ access: string
88
+ expires: number
89
+ accountId?: string
90
+ enterpriseUrl?: string
91
+ }
92
+
93
+ interface WellKnownInfo {
94
+ type: "wellknown"
95
+ key: string
96
+ token: string
97
+ }
98
+
99
+ export type AuthInfo = ApiAuthInfo | OAuthInfo | WellKnownInfo
100
+
101
+ export interface SDKResponseLike<T> {
102
+ data?: T
103
+ }
104
+
105
+ export interface PluginClientLike {
106
+ session: {
107
+ get(
108
+ input: (
109
+ | { sessionID: string; directory?: string; workspace?: string }
110
+ | { path: { id: string }; query?: { directory?: string; workspace?: string } }
111
+ ) & { throwOnError?: boolean },
112
+ options?: { throwOnError?: boolean },
113
+ ): Promise<SessionLike | SDKResponseLike<SessionLike>>
114
+ messages(
115
+ input: (
116
+ | { sessionID: string; directory?: string; workspace?: string }
117
+ | { path: { id: string }; query?: { directory?: string; workspace?: string; limit?: number; before?: string } }
118
+ ) & { throwOnError?: boolean },
119
+ options?: { throwOnError?: boolean },
120
+ ): Promise<MessageWithPartsLike[] | SDKResponseLike<MessageWithPartsLike[]>>
121
+ message(
122
+ input: (
123
+ | { sessionID: string; messageID: string; directory?: string; workspace?: string }
124
+ | { path: { id: string; messageID: string }; query?: { directory?: string; workspace?: string } }
125
+ ) & { throwOnError?: boolean },
126
+ options?: { throwOnError?: boolean },
127
+ ): Promise<MessageWithPartsLike | SDKResponseLike<MessageWithPartsLike>>
128
+ }
129
+ provider: {
130
+ list(options?: {
131
+ throwOnError?: boolean
132
+ }): Promise<ProviderListResponseLike | SDKResponseLike<ProviderListResponseLike>>
133
+ }
134
+ auth: {
135
+ set(input: { path: { id: string }; body: AuthInfo }): Promise<unknown>
136
+ }
137
+ app: {
138
+ log(input: {
139
+ body: {
140
+ service: string
141
+ level: string
142
+ message: string
143
+ extra?: Record<string, unknown>
144
+ }
145
+ }): Promise<unknown>
146
+ }
147
+ }
package/src/constants.ts CHANGED
@@ -1,261 +1,5 @@
1
- export const PLUGIN_NAME = "opencode-translate"
2
- export const SPEC_VERSION = 1
3
- export const LLM_LANGUAGE = "en"
4
- export const DEFAULT_TRANSLATOR_MODEL = "anthropic/claude-haiku-4-5"
5
- export const DEFAULT_TRIGGER_KEYWORDS = ["$en"]
6
- export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key"
7
- export const NONCE_PATTERN = /^[0-9a-f]{32}$/
8
- export const FAILURE_NOTICE = "_Translation unavailable for this segment._"
9
- export const AUTH_ENV_FALLBACK = "the provider's API key env var"
10
- export const USER_AGENT = `${PLUGIN_NAME}/0.0.0`
11
-
12
- export type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
13
-
14
- export type ProviderSource = "env" | "config" | "custom" | "api"
15
-
16
- export interface TranslateOptions {
17
- translatorModel?: string
18
- triggerKeywords?: string[]
19
- sourceLanguage?: string
20
- displayLanguage?: string
21
- apiKey?: string
22
- verbose?: boolean
23
- }
24
-
25
- export interface ResolvedTranslateOptions {
26
- translatorModel: string
27
- triggerKeywords: string[]
28
- sourceLanguage: string
29
- displayLanguage: string
30
- apiKey?: string
31
- verbose: boolean
32
- }
33
-
34
- export interface TranslateState {
35
- translate_enabled: true
36
- translate_source_lang: string
37
- translate_display_lang: string
38
- translate_llm_lang: typeof LLM_LANGUAGE
39
- translate_nonce: string
40
- }
41
-
42
- export interface StoredTextMetadata extends Record<string, unknown> {
43
- translate_enabled?: boolean
44
- translate_source_lang?: string
45
- translate_display_lang?: string
46
- translate_llm_lang?: string
47
- translate_nonce?: string
48
- translate_role?: string
49
- translate_spec_version?: number
50
- translate_source_hash?: string
51
- translate_en?: string
52
- translate_part_index?: number
53
- compaction_continue?: boolean
54
- }
55
-
56
- export interface SessionLike {
57
- id: string
58
- parentID?: string | null
59
- }
60
-
61
- export interface MessageLike {
62
- id: string
63
- sessionID: string
64
- role: string
65
- }
66
-
67
- export interface TextPartLike {
68
- id: string
69
- sessionID: string
70
- messageID: string
71
- type: string
72
- text?: string
73
- synthetic?: boolean
74
- ignored?: boolean
75
- metadata?: Record<string, unknown>
76
- }
77
-
78
- export interface MessageWithPartsLike {
79
- info: MessageLike
80
- parts: TextPartLike[]
81
- }
82
-
83
- export interface ProviderInfo {
84
- id: string
85
- source: ProviderSource
86
- env: string[]
87
- key?: string
88
- options?: Record<string, unknown>
89
- models?: Record<string, unknown>
90
- }
91
-
92
- export interface ProviderListResponseLike {
93
- all: ProviderInfo[]
94
- }
95
-
96
- export interface ApiAuthInfo {
97
- type: "api"
98
- key: string
99
- metadata?: Record<string, string>
100
- }
101
-
102
- export interface OAuthInfo {
103
- type: "oauth"
104
- refresh: string
105
- access: string
106
- expires: number
107
- accountId?: string
108
- enterpriseUrl?: string
109
- }
110
-
111
- export interface WellKnownInfo {
112
- type: "wellknown"
113
- key: string
114
- token: string
115
- }
116
-
117
- export type AuthInfo = ApiAuthInfo | OAuthInfo | WellKnownInfo
118
-
119
- export interface SDKResponseLike<T> {
120
- data?: T
121
- }
122
-
123
- export interface PluginClientLike {
124
- session: {
125
- get(
126
- input: (
127
- | { sessionID: string; directory?: string; workspace?: string }
128
- | { path: { id: string }; query?: { directory?: string; workspace?: string } }
129
- ) & { throwOnError?: boolean },
130
- options?: { throwOnError?: boolean },
131
- ): Promise<SessionLike | SDKResponseLike<SessionLike>>
132
- messages(
133
- input: (
134
- | { sessionID: string; directory?: string; workspace?: string }
135
- | { path: { id: string }; query?: { directory?: string; workspace?: string; limit?: number; before?: string } }
136
- ) & { throwOnError?: boolean },
137
- options?: { throwOnError?: boolean },
138
- ): Promise<MessageWithPartsLike[] | SDKResponseLike<MessageWithPartsLike[]>>
139
- message(
140
- input: (
141
- | { sessionID: string; messageID: string; directory?: string; workspace?: string }
142
- | { path: { id: string; messageID: string }; query?: { directory?: string; workspace?: string } }
143
- ) & { throwOnError?: boolean },
144
- options?: { throwOnError?: boolean },
145
- ): Promise<MessageWithPartsLike | SDKResponseLike<MessageWithPartsLike>>
146
- }
147
- provider: {
148
- list(options?: {
149
- throwOnError?: boolean
150
- }): Promise<ProviderListResponseLike | SDKResponseLike<ProviderListResponseLike>>
151
- }
152
- auth: {
153
- set(input: { path: { id: string }; body: AuthInfo }): Promise<unknown>
154
- }
155
- app: {
156
- log(input: {
157
- body: {
158
- service: string
159
- level: string
160
- message: string
161
- extra?: Record<string, unknown>
162
- }
163
- }): Promise<unknown>
164
- }
165
- }
166
-
167
- export interface TranslationPreviewInfo {
168
- english: string
169
- sourceHash: string
170
- eligibleIndex: number
171
- }
172
-
173
- export function resolveOptions(options: Record<string, unknown>): ResolvedTranslateOptions {
174
- const triggerKeywords = Array.isArray(options.triggerKeywords)
175
- ? options.triggerKeywords.filter((value): value is string => typeof value === "string" && value.length > 0)
176
- : DEFAULT_TRIGGER_KEYWORDS
177
-
178
- return {
179
- translatorModel:
180
- typeof options.translatorModel === "string" && options.translatorModel.includes("/")
181
- ? options.translatorModel
182
- : DEFAULT_TRANSLATOR_MODEL,
183
- triggerKeywords: triggerKeywords.length > 0 ? triggerKeywords : [...DEFAULT_TRIGGER_KEYWORDS],
184
- sourceLanguage:
185
- typeof options.sourceLanguage === "string" && options.sourceLanguage.trim() ? options.sourceLanguage : "en",
186
- displayLanguage:
187
- typeof options.displayLanguage === "string" && options.displayLanguage.trim() ? options.displayLanguage : "en",
188
- apiKey: typeof options.apiKey === "string" && options.apiKey.length > 0 ? options.apiKey : undefined,
189
- verbose: options.verbose === true,
190
- }
191
- }
192
-
193
- export function getEnvVarHint(provider: ProviderInfo | undefined): string {
194
- return provider?.env[0] || AUTH_ENV_FALLBACK
195
- }
196
-
197
- export function isNonEmptyString(value: unknown): value is string {
198
- return typeof value === "string" && value.length > 0
199
- }
200
-
201
- export function unwrapData<T>(value: T | SDKResponseLike<T>): T {
202
- if (value && typeof value === "object" && "data" in value && (value as SDKResponseLike<T>).data !== undefined) {
203
- return (value as SDKResponseLike<T>).data as T
204
- }
205
- return value as T
206
- }
207
-
208
- export function normalizeReason(error: unknown): string {
209
- const raw = error instanceof Error ? error.message : String(error)
210
- return raw.split(/\r?\n/, 1)[0].trim().slice(0, 200)
211
- }
212
-
213
- export function buildInboundTranslationError(sourceLanguage: string, reason: string): Error {
214
- return new Error(
215
- `[${PLUGIN_NAME}:INBOUND_TRANSLATION_FAILED] Failed to translate user message from ${sourceLanguage} to en: ${reason}`,
216
- )
217
- }
218
-
219
- export function buildAuthUnavailableError(providerID: string, envVar: string): Error {
220
- return new Error(
221
- `[${PLUGIN_NAME}:AUTH_UNAVAILABLE] No credential found for provider "${providerID}". Set ${envVar} in the environment, run "opencode auth login ${providerID}", or set options.apiKey in opencode.json.`,
222
- )
223
- }
224
-
225
- export function buildOAuthRefreshError(providerID: string, reason: string): Error {
226
- return new Error(
227
- `[${PLUGIN_NAME}:OAUTH_REFRESH_FAILED] Failed to refresh OAuth token for provider "${providerID}": ${reason}. Re-authenticate with "opencode auth login ${providerID}".`,
228
- )
229
- }
230
-
231
- export function isTranslateStateRecord(value: unknown): value is TranslateState {
232
- if (!value || typeof value !== "object") return false
233
- const record = value as Record<string, unknown>
234
- return (
235
- record.translate_enabled === true &&
236
- record.translate_llm_lang === LLM_LANGUAGE &&
237
- isNonEmptyString(record.translate_source_lang) &&
238
- isNonEmptyString(record.translate_display_lang) &&
239
- isNonEmptyString(record.translate_nonce) &&
240
- NONCE_PATTERN.test(record.translate_nonce)
241
- )
242
- }
243
-
244
- export function isTextPart(part: TextPartLike): part is TextPartLike & { text: string } {
245
- return part.type === "text" && typeof part.text === "string"
246
- }
247
-
248
- export function isUserAuthoredTextPart(part: TextPartLike): part is TextPartLike & { text: string } {
249
- return isTextPart(part) && part.synthetic !== true && part.ignored !== true
250
- }
251
-
252
- export function parseTranslatorModel(model: string): { providerID: string; modelID: string } {
253
- const slash = model.indexOf("/")
254
- if (slash < 1 || slash === model.length - 1) {
255
- return { providerID: "anthropic", modelID: model }
256
- }
257
- return {
258
- providerID: model.slice(0, slash),
259
- modelID: model.slice(slash + 1),
260
- }
261
- }
1
+ export * from "./constants/errors"
2
+ export * from "./constants/guards"
3
+ export * from "./constants/options"
4
+ export * from "./constants/plugin"
5
+ export * from "./constants/types"
package/src/labels.ts CHANGED
@@ -13,5 +13,3 @@ const DISPLAY_LANGUAGE_LABELS: Record<string, string> = {
13
13
  export function getDisplayLanguageLabel(displayLanguage: string): string {
14
14
  return DISPLAY_LANGUAGE_LABELS[displayLanguage] ?? `Translation (${displayLanguage})`
15
15
  }
16
-
17
- export { DISPLAY_LANGUAGE_LABELS }
@@ -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
+ }