opencode-translate 0.2.1 → 0.2.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 +1 -1
- package/package.json +1 -1
- package/src/activation/index.ts +1 -1
- package/src/activation/question-hooks.ts +25 -17
- package/src/auth/index.ts +7 -8
- package/src/question-tool.ts +28 -16
- package/src/translator/index.ts +6 -6
package/README.md
CHANGED
|
@@ -44,7 +44,7 @@ Hooks never throw. If the translator fails (network error, auth failure, provide
|
|
|
44
44
|
3. Falls back to sending the original (untranslated) user text to the model.
|
|
45
45
|
4. On activation-turn failure, it also rolls back activation so the next turn retries cleanly.
|
|
46
46
|
|
|
47
|
-
A stalled provider request is additionally bounded by a
|
|
47
|
+
A stalled provider request is additionally bounded by a 180s hard timeout per translation call, so a hung upstream cannot block the OpenCode session.
|
|
48
48
|
|
|
49
49
|
## Install
|
|
50
50
|
|
package/package.json
CHANGED
package/src/activation/index.ts
CHANGED
|
@@ -19,7 +19,7 @@ export function __resetActivationCacheForTest() {
|
|
|
19
19
|
export function createHooks(ctx: PluginInput, rawOptions: PluginOptions = {}, deps: HookDependencies = {}): Hooks {
|
|
20
20
|
if (process.env.OPENCODE_TRANSLATE_DISABLE === "1") return {}
|
|
21
21
|
|
|
22
|
-
const client = ctx.client as
|
|
22
|
+
const client = ctx.client as PluginClientLike
|
|
23
23
|
const options = resolveOptions(rawOptions)
|
|
24
24
|
const hookContext: HookContext = {
|
|
25
25
|
client,
|
|
@@ -12,8 +12,19 @@ import { logError } from "./logging"
|
|
|
12
12
|
import { resolveSessionState } from "./state"
|
|
13
13
|
import { type HookContext, QUESTION_TOOL_ID } from "./types"
|
|
14
14
|
|
|
15
|
+
const QUESTION_SNAPSHOT_LIMIT = 1_000
|
|
16
|
+
|
|
15
17
|
const questionSnapshots = new Map<string, QuestionSnapshot>()
|
|
16
18
|
|
|
19
|
+
function pruneQuestionSnapshots() {
|
|
20
|
+
while (questionSnapshots.size > QUESTION_SNAPSHOT_LIMIT) {
|
|
21
|
+
for (const callID of questionSnapshots.keys()) {
|
|
22
|
+
questionSnapshots.delete(callID)
|
|
23
|
+
break
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
17
28
|
export function resetQuestionSnapshots() {
|
|
18
29
|
questionSnapshots.clear()
|
|
19
30
|
}
|
|
@@ -26,8 +37,8 @@ export function createToolExecuteBeforeHook(ctx: HookContext): NonNullable<Hooks
|
|
|
26
37
|
const activeState = resolved.state
|
|
27
38
|
if (!activeState) return
|
|
28
39
|
|
|
29
|
-
|
|
30
|
-
|
|
40
|
+
if (!isQuestionArgs(output.args)) return
|
|
41
|
+
const args = output.args
|
|
31
42
|
|
|
32
43
|
const original = snapshotQuestions(args)
|
|
33
44
|
if (activeState.translate_user_lang !== LLM_LANGUAGE) {
|
|
@@ -48,6 +59,7 @@ export function createToolExecuteBeforeHook(ctx: HookContext): NonNullable<Hooks
|
|
|
48
59
|
}
|
|
49
60
|
|
|
50
61
|
questionSnapshots.set(input.callID, { original, translated: snapshotQuestions(args) })
|
|
62
|
+
pruneQuestionSnapshots()
|
|
51
63
|
} catch (error) {
|
|
52
64
|
await logError(ctx.client, error)
|
|
53
65
|
}
|
|
@@ -64,24 +76,20 @@ export function createToolExecuteAfterHook(ctx: HookContext): NonNullable<Hooks[
|
|
|
64
76
|
|
|
65
77
|
const resolved = await resolveSessionState(ctx.client, ctx.directory, input.sessionID)
|
|
66
78
|
const activeState = resolved.state
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
text,
|
|
72
|
-
sourceLanguage: activeState.translate_user_lang,
|
|
73
|
-
targetLanguage: LLM_LANGUAGE,
|
|
74
|
-
direction: "inbound",
|
|
75
|
-
})
|
|
76
|
-
: undefined
|
|
79
|
+
if (!activeState || activeState.translate_user_lang === LLM_LANGUAGE) {
|
|
80
|
+
await restoreQuestionOutput(output as QuestionToolOutput, snapshot)
|
|
81
|
+
return
|
|
82
|
+
}
|
|
77
83
|
|
|
78
84
|
await restoreQuestionOutput(output as QuestionToolOutput, snapshot, {
|
|
79
|
-
|
|
85
|
+
translateCustomAnswer: (text: string) =>
|
|
86
|
+
ctx.translator.translateText({
|
|
87
|
+
text,
|
|
88
|
+
sourceLanguage: activeState.translate_user_lang,
|
|
89
|
+
targetLanguage: LLM_LANGUAGE,
|
|
90
|
+
direction: "inbound",
|
|
91
|
+
}),
|
|
80
92
|
onTranslationError: async (error) => {
|
|
81
|
-
if (!activeState) {
|
|
82
|
-
await logError(ctx.client, error)
|
|
83
|
-
return
|
|
84
|
-
}
|
|
85
93
|
await logError(
|
|
86
94
|
ctx.client,
|
|
87
95
|
buildInboundTranslationError(activeState.translate_user_lang, normalizeReason(error)),
|
package/src/auth/index.ts
CHANGED
|
@@ -18,12 +18,8 @@ import { refreshAnthropic, refreshOpenAI } from "./refresh"
|
|
|
18
18
|
import { ensureOAuthInfo, normalizeProviderKey, readAuthMap } from "./store"
|
|
19
19
|
import type { AuthDependencies, AuthRuntime, ResolvedCredential } from "./types"
|
|
20
20
|
|
|
21
|
-
const credentialCache = new Map<string, ResolvedCredential>()
|
|
22
|
-
const oauthRefreshInflight = new Map<string, Promise<OAuthInfo>>()
|
|
23
|
-
|
|
24
21
|
export function __resetAuthCachesForTest() {
|
|
25
|
-
|
|
26
|
-
oauthRefreshInflight.clear()
|
|
22
|
+
// Resolver instances own their caches; this remains as a stable test helper.
|
|
27
23
|
}
|
|
28
24
|
|
|
29
25
|
function isMissingCredentialError(error: unknown): boolean {
|
|
@@ -72,6 +68,8 @@ export function createCredentialResolver(
|
|
|
72
68
|
options: ResolvedTranslateOptions,
|
|
73
69
|
deps: AuthDependencies = {},
|
|
74
70
|
) {
|
|
71
|
+
const credentialCache = new Map<string, ResolvedCredential>()
|
|
72
|
+
const oauthRefreshInflight = new Map<string, Promise<OAuthInfo>>()
|
|
75
73
|
const runtime: AuthRuntime = {
|
|
76
74
|
fetchImpl: deps.fetchImpl ?? fetch,
|
|
77
75
|
sleep: deps.sleep ?? ((ms: number) => sleep(ms)),
|
|
@@ -84,13 +82,14 @@ export function createCredentialResolver(
|
|
|
84
82
|
if (!info) return undefined
|
|
85
83
|
if (info.expires >= now() + 60_000) return info
|
|
86
84
|
|
|
87
|
-
const
|
|
85
|
+
const inflightKey = `${providerID}:${info.refresh}`
|
|
86
|
+
const existing = oauthRefreshInflight.get(inflightKey)
|
|
88
87
|
if (existing) return existing
|
|
89
88
|
|
|
90
89
|
const refreshPromise = refreshProviderOAuth(providerID, info, client, runtime).finally(() => {
|
|
91
|
-
oauthRefreshInflight.delete(
|
|
90
|
+
oauthRefreshInflight.delete(inflightKey)
|
|
92
91
|
})
|
|
93
|
-
oauthRefreshInflight.set(
|
|
92
|
+
oauthRefreshInflight.set(inflightKey, refreshPromise)
|
|
94
93
|
return refreshPromise
|
|
95
94
|
}
|
|
96
95
|
|
package/src/question-tool.ts
CHANGED
|
@@ -76,35 +76,47 @@ export function isQuestionArgs(value: unknown): value is QuestionArgs {
|
|
|
76
76
|
return true
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
-
async function
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
translate: (text: string) => Promise<string>,
|
|
83
|
-
): Promise<void> {
|
|
84
|
-
const original = container[key]
|
|
85
|
-
if (!original || original.length === 0) return
|
|
86
|
-
const translated = await translate(original)
|
|
87
|
-
container[key] = unwrapEchoedTextEnvelope(translated)
|
|
79
|
+
async function translatedDisplayText(text: string, translate: (text: string) => Promise<string>): Promise<string> {
|
|
80
|
+
if (text.length === 0) return text
|
|
81
|
+
return unwrapEchoedTextEnvelope(await translate(text))
|
|
88
82
|
}
|
|
89
83
|
|
|
90
|
-
// Translate every display-facing string in
|
|
91
|
-
//
|
|
84
|
+
// Translate every display-facing string in parallel, then commit the translated
|
|
85
|
+
// clone only after every translation succeeds.
|
|
92
86
|
export async function translateQuestionArgs(
|
|
93
87
|
args: QuestionArgs,
|
|
94
88
|
translate: (text: string) => Promise<string>,
|
|
95
89
|
): Promise<void> {
|
|
90
|
+
const translatedQuestions = snapshotQuestions(args)
|
|
96
91
|
const jobs: Promise<void>[] = []
|
|
97
92
|
|
|
98
|
-
for (const q of
|
|
99
|
-
jobs.push(
|
|
100
|
-
|
|
93
|
+
for (const q of translatedQuestions) {
|
|
94
|
+
jobs.push(
|
|
95
|
+
(async () => {
|
|
96
|
+
q.question = await translatedDisplayText(q.question, translate)
|
|
97
|
+
})(),
|
|
98
|
+
)
|
|
99
|
+
jobs.push(
|
|
100
|
+
(async () => {
|
|
101
|
+
q.header = await translatedDisplayText(q.header, translate)
|
|
102
|
+
})(),
|
|
103
|
+
)
|
|
101
104
|
for (const option of q.options) {
|
|
102
|
-
jobs.push(
|
|
103
|
-
|
|
105
|
+
jobs.push(
|
|
106
|
+
(async () => {
|
|
107
|
+
option.label = await translatedDisplayText(option.label, translate)
|
|
108
|
+
})(),
|
|
109
|
+
)
|
|
110
|
+
jobs.push(
|
|
111
|
+
(async () => {
|
|
112
|
+
option.description = await translatedDisplayText(option.description, translate)
|
|
113
|
+
})(),
|
|
114
|
+
)
|
|
104
115
|
}
|
|
105
116
|
}
|
|
106
117
|
|
|
107
118
|
await Promise.all(jobs)
|
|
119
|
+
args.questions.splice(0, args.questions.length, ...translatedQuestions)
|
|
108
120
|
}
|
|
109
121
|
|
|
110
122
|
// Given a user-selected label, find the matching translated option and return
|
package/src/translator/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { setTimeout as sleep } from "node:timers/promises"
|
|
2
|
-
import { generateText } from "ai"
|
|
2
|
+
import { generateText, type LanguageModel } from "ai"
|
|
3
3
|
import { createCredentialResolver } from "../auth"
|
|
4
4
|
import {
|
|
5
5
|
buildAuthUnavailableError,
|
|
@@ -73,20 +73,20 @@ export function createTranslator(
|
|
|
73
73
|
const credentials = await credentialResolver.resolve(options.translatorModel)
|
|
74
74
|
const factory = await loadFactory(providerID)
|
|
75
75
|
const provider = instantiateProvider(factory, providerID, credentials)
|
|
76
|
-
const model = instantiateModel(provider, modelID)
|
|
76
|
+
const model = instantiateModel(provider, modelID) as LanguageModel
|
|
77
77
|
|
|
78
78
|
const rawTranslated = await withRetry(async () => {
|
|
79
79
|
try {
|
|
80
|
-
const result =
|
|
80
|
+
const result = await withTimeout(
|
|
81
81
|
generateTextImpl({
|
|
82
|
-
model
|
|
82
|
+
model,
|
|
83
83
|
system: buildSystemPrompt(input),
|
|
84
84
|
...(supportsTemperature(providerID, modelID) ? { temperature: 0 } : {}),
|
|
85
85
|
prompt: buildUserPrompt(input),
|
|
86
|
-
})
|
|
86
|
+
}),
|
|
87
87
|
timeoutMs,
|
|
88
88
|
"Translator generateText",
|
|
89
|
-
)
|
|
89
|
+
)
|
|
90
90
|
return result.text
|
|
91
91
|
} catch (error) {
|
|
92
92
|
if (isAuthMessage(error)) throw error
|