opencode-translate 1.0.1 → 1.0.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.
- package/README.md +5 -1
- package/package.json +1 -1
- package/src/activation/event.ts +132 -0
- package/src/activation/index.ts +6 -1
- package/src/activation/text-complete.ts +2 -0
- package/src/activation/types.ts +1 -0
- package/src/constants/options.ts +16 -1
- package/src/constants/types.ts +19 -0
- package/src/translator/index.ts +3 -0
- package/src/translator/provider.ts +55 -1
package/README.md
CHANGED
|
@@ -35,7 +35,9 @@ Add to `~/.config/opencode/opencode.jsonc`:
|
|
|
35
35
|
"plugin": [
|
|
36
36
|
["opencode-translate", {
|
|
37
37
|
"model": "openai/gpt-5.4-mini", // model to use for translation
|
|
38
|
-
"
|
|
38
|
+
"variant": "minimal", // optional model variant / thinking effort
|
|
39
|
+
"lang": "Korean", // language you speak
|
|
40
|
+
"assistantTranslation": "final-message" // or "each-part"
|
|
39
41
|
}]
|
|
40
42
|
]
|
|
41
43
|
}
|
|
@@ -56,6 +58,8 @@ All subsequent messages in the same session are translated automatically — no
|
|
|
56
58
|
| Option | Type | Default | Description |
|
|
57
59
|
| --- | --- | --- | --- |
|
|
58
60
|
| `model` | string | required | Translator model in `provider/model-id` form |
|
|
61
|
+
| `variant` | string | optional | Translator model variant / thinking effort (for example, `"minimal"`, `"high"`, or `"max"`) |
|
|
59
62
|
| `lang` | string | required | Language you speak (e.g. `"Korean"`, `"Japanese"`) |
|
|
60
63
|
| `trigger` | string[] | `["$en"]` | Keywords that activate translation |
|
|
64
|
+
| `assistantTranslation` | `"each-part" \| "final-message"` | `"final-message"` | Translate only the final assistant text part after the loop becomes idle, or opt into translating each text part as it completes |
|
|
61
65
|
| `verbose` | boolean | `false` | Print translation logs |
|
package/package.json
CHANGED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import type { Hooks } from "@opencode-ai/plugin"
|
|
2
|
+
import { isTextPart, LLM_LANGUAGE, type MessageWithPartsLike, type TextPartLike, unwrapData } from "../constants"
|
|
3
|
+
import { composeTranslatedAssistantText, composeTranslationFailureText, extractEnglishHistoryText } from "../formatting"
|
|
4
|
+
import { getDisplayLanguageLabel } from "../labels"
|
|
5
|
+
import { logError } from "./logging"
|
|
6
|
+
import { resolveSessionState } from "./state"
|
|
7
|
+
import type { HookContext } from "./types"
|
|
8
|
+
|
|
9
|
+
type EventHook = NonNullable<Hooks["event"]>
|
|
10
|
+
|
|
11
|
+
const inFlightFinalTranslations = new Set<string>()
|
|
12
|
+
|
|
13
|
+
function latestAssistantMessage(messages: MessageWithPartsLike[]): MessageWithPartsLike | undefined {
|
|
14
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
15
|
+
const message = messages[index]
|
|
16
|
+
if (message.info.role === "assistant" && message.info.summary !== true) return message
|
|
17
|
+
}
|
|
18
|
+
return undefined
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function lastNonEmptyTextPart(message: MessageWithPartsLike): (TextPartLike & { text: string }) | undefined {
|
|
22
|
+
for (let index = message.parts.length - 1; index >= 0; index -= 1) {
|
|
23
|
+
const part = message.parts[index]
|
|
24
|
+
if (isTextPart(part) && part.text.trim().length > 0) return part
|
|
25
|
+
}
|
|
26
|
+
return undefined
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function composeFinalAssistantText(
|
|
30
|
+
ctx: HookContext,
|
|
31
|
+
text: string,
|
|
32
|
+
targetLanguage: string,
|
|
33
|
+
label: string,
|
|
34
|
+
): Promise<string> {
|
|
35
|
+
try {
|
|
36
|
+
const translated = await ctx.translator.translateText({
|
|
37
|
+
text,
|
|
38
|
+
sourceLanguage: LLM_LANGUAGE,
|
|
39
|
+
targetLanguage,
|
|
40
|
+
direction: "outbound",
|
|
41
|
+
})
|
|
42
|
+
return composeTranslatedAssistantText(text, label, translated)
|
|
43
|
+
} catch (error) {
|
|
44
|
+
await logError(ctx.client, error)
|
|
45
|
+
return composeTranslationFailureText(text)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function patchPartViaServer(ctx: HookContext, sessionID: string, part: TextPartLike) {
|
|
50
|
+
if (!ctx.serverUrl) throw new Error("client.part.update is required for final-message assistant translation")
|
|
51
|
+
|
|
52
|
+
const url = new URL(
|
|
53
|
+
`/session/${encodeURIComponent(sessionID)}/message/${encodeURIComponent(part.messageID)}/part/${encodeURIComponent(part.id)}`,
|
|
54
|
+
ctx.serverUrl,
|
|
55
|
+
)
|
|
56
|
+
if (ctx.directory) url.searchParams.set("directory", ctx.directory)
|
|
57
|
+
|
|
58
|
+
const response = await fetch(url, {
|
|
59
|
+
method: "PATCH",
|
|
60
|
+
headers: { "content-type": "application/json" },
|
|
61
|
+
body: JSON.stringify(part),
|
|
62
|
+
})
|
|
63
|
+
if (!response.ok) {
|
|
64
|
+
throw new Error(`Failed to update final assistant translation part: HTTP ${response.status}`)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function updatePart(ctx: HookContext, sessionID: string, part: TextPartLike) {
|
|
69
|
+
const partUpdater = ctx.client.part?.update
|
|
70
|
+
if (partUpdater) {
|
|
71
|
+
const input = {
|
|
72
|
+
sessionID,
|
|
73
|
+
messageID: part.messageID,
|
|
74
|
+
partID: part.id,
|
|
75
|
+
...(ctx.directory ? { directory: ctx.directory } : {}),
|
|
76
|
+
part,
|
|
77
|
+
}
|
|
78
|
+
await partUpdater.call(ctx.client.part, input, { throwOnError: true })
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
await patchPartViaServer(ctx, sessionID, part)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function translateFinalAssistantMessage(ctx: HookContext, sessionID: string) {
|
|
86
|
+
if (inFlightFinalTranslations.has(sessionID)) return
|
|
87
|
+
inFlightFinalTranslations.add(sessionID)
|
|
88
|
+
try {
|
|
89
|
+
const resolved = await resolveSessionState(ctx.client, ctx.directory, sessionID)
|
|
90
|
+
const activeState = resolved.state
|
|
91
|
+
if (!activeState) return
|
|
92
|
+
if (activeState.translate_user_lang === LLM_LANGUAGE) return
|
|
93
|
+
|
|
94
|
+
const messages = unwrapData(
|
|
95
|
+
await ctx.client.session.messages({
|
|
96
|
+
path: { id: sessionID },
|
|
97
|
+
query: { ...(ctx.directory ? { directory: ctx.directory } : {}), limit: 20 },
|
|
98
|
+
throwOnError: true,
|
|
99
|
+
}),
|
|
100
|
+
)
|
|
101
|
+
const message = latestAssistantMessage(messages)
|
|
102
|
+
if (!message) return
|
|
103
|
+
|
|
104
|
+
const part = lastNonEmptyTextPart(message)
|
|
105
|
+
if (!part) return
|
|
106
|
+
|
|
107
|
+
const label = getDisplayLanguageLabel(activeState.translate_user_lang)
|
|
108
|
+
if (extractEnglishHistoryText(part.text, { nonce: activeState.translate_nonce, label }) !== part.text) return
|
|
109
|
+
|
|
110
|
+
const updatedPart = {
|
|
111
|
+
...part,
|
|
112
|
+
text: await composeFinalAssistantText(ctx, part.text, activeState.translate_user_lang, label),
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
await updatePart(ctx, sessionID, updatedPart)
|
|
116
|
+
} finally {
|
|
117
|
+
inFlightFinalTranslations.delete(sessionID)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function createEventHook(ctx: HookContext): EventHook {
|
|
122
|
+
return async (input) => {
|
|
123
|
+
if (ctx.options.assistantTranslation !== "final-message") return
|
|
124
|
+
if (input.event.type !== "session.idle") return
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
await translateFinalAssistantMessage(ctx, input.event.properties.sessionID)
|
|
128
|
+
} catch (error) {
|
|
129
|
+
await logError(ctx.client, error)
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
package/src/activation/index.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { Hooks, PluginInput, PluginOptions } from "@opencode-ai/plugin"
|
|
|
2
2
|
import { type PluginClientLike, resolveOptions } from "../constants"
|
|
3
3
|
import { createTranslator } from "../translator"
|
|
4
4
|
import { createChatMessageHook } from "./chat-message"
|
|
5
|
+
import { createEventHook } from "./event"
|
|
5
6
|
import { createMessagesTransformHook } from "./messages-transform"
|
|
6
7
|
import { createToolExecuteAfterHook, createToolExecuteBeforeHook, resetQuestionSnapshots } from "./question-hooks"
|
|
7
8
|
import { resetSessionStateCache } from "./state"
|
|
@@ -24,15 +25,19 @@ export function createHooks(ctx: PluginInput, rawOptions: PluginOptions = {}, de
|
|
|
24
25
|
const hookContext: HookContext = {
|
|
25
26
|
client,
|
|
26
27
|
directory: ctx.directory,
|
|
28
|
+
serverUrl: ctx.serverUrl,
|
|
27
29
|
options,
|
|
28
30
|
translator: deps.translator ?? createTranslator(client, options),
|
|
29
31
|
}
|
|
30
32
|
|
|
31
|
-
|
|
33
|
+
const hooks: Hooks = {
|
|
32
34
|
"chat.message": createChatMessageHook(hookContext),
|
|
33
35
|
"experimental.chat.messages.transform": createMessagesTransformHook(hookContext),
|
|
34
36
|
"experimental.text.complete": createTextCompleteHook(hookContext),
|
|
35
37
|
"tool.execute.before": createToolExecuteBeforeHook(hookContext),
|
|
36
38
|
"tool.execute.after": createToolExecuteAfterHook(hookContext),
|
|
37
39
|
}
|
|
40
|
+
|
|
41
|
+
if (options.assistantTranslation === "final-message") hooks.event = createEventHook(hookContext)
|
|
42
|
+
return hooks
|
|
38
43
|
}
|
|
@@ -11,6 +11,8 @@ type TextCompleteHook = NonNullable<Hooks["experimental.text.complete"]>
|
|
|
11
11
|
export function createTextCompleteHook(ctx: HookContext): TextCompleteHook {
|
|
12
12
|
return async (input, output) => {
|
|
13
13
|
try {
|
|
14
|
+
if (ctx.options.assistantTranslation === "final-message") return
|
|
15
|
+
|
|
14
16
|
const resolved = await resolveSessionState(ctx.client, ctx.directory, input.sessionID)
|
|
15
17
|
const activeState = resolved.state
|
|
16
18
|
if (!activeState) return
|
package/src/activation/types.ts
CHANGED
package/src/constants/options.ts
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
import { AUTH_ENV_FALLBACK, DEFAULT_TRIGGER, PLUGIN_NAME } from "./plugin"
|
|
2
|
-
import type { ProviderInfo, ResolvedTranslateOptions } from "./types"
|
|
2
|
+
import type { AssistantTranslationMode, ProviderInfo, ResolvedTranslateOptions } from "./types"
|
|
3
|
+
|
|
4
|
+
const ASSISTANT_TRANSLATION_MODES = new Set<AssistantTranslationMode>(["each-part", "final-message"])
|
|
5
|
+
|
|
6
|
+
function resolveAssistantTranslationMode(value: unknown): AssistantTranslationMode {
|
|
7
|
+
if (value === undefined) return "final-message"
|
|
8
|
+
if (typeof value === "string" && ASSISTANT_TRANSLATION_MODES.has(value as AssistantTranslationMode)) {
|
|
9
|
+
return value as AssistantTranslationMode
|
|
10
|
+
}
|
|
11
|
+
throw new Error(
|
|
12
|
+
`[${PLUGIN_NAME}:INVALID_OPTIONS] options.assistantTranslation must be "each-part" or "final-message".`,
|
|
13
|
+
)
|
|
14
|
+
}
|
|
3
15
|
|
|
4
16
|
export function resolveOptions(options: Record<string, unknown>): ResolvedTranslateOptions {
|
|
5
17
|
const model = typeof options.model === "string" ? options.model.trim() : ""
|
|
@@ -21,6 +33,7 @@ export function resolveOptions(options: Record<string, unknown>): ResolvedTransl
|
|
|
21
33
|
`[${PLUGIN_NAME}:INVALID_OPTIONS] options.lang is required. Set it to the user's language, e.g. "Korean" or "Japanese".`,
|
|
22
34
|
)
|
|
23
35
|
}
|
|
36
|
+
const variant = typeof options.variant === "string" ? options.variant.trim() : ""
|
|
24
37
|
|
|
25
38
|
const rawTrigger = Array.isArray(options.trigger)
|
|
26
39
|
? options.trigger
|
|
@@ -31,9 +44,11 @@ export function resolveOptions(options: Record<string, unknown>): ResolvedTransl
|
|
|
31
44
|
|
|
32
45
|
return {
|
|
33
46
|
model,
|
|
47
|
+
...(variant ? { variant } : {}),
|
|
34
48
|
trigger: trigger.length > 0 ? trigger : [...DEFAULT_TRIGGER],
|
|
35
49
|
lang,
|
|
36
50
|
verbose: options.verbose === true,
|
|
51
|
+
assistantTranslation: resolveAssistantTranslationMode(options.assistantTranslation),
|
|
37
52
|
}
|
|
38
53
|
}
|
|
39
54
|
|
package/src/constants/types.ts
CHANGED
|
@@ -6,11 +6,15 @@ type ProviderSource = "env" | "config" | "custom" | "api"
|
|
|
6
6
|
|
|
7
7
|
export interface ResolvedTranslateOptions {
|
|
8
8
|
model: string
|
|
9
|
+
variant?: string
|
|
9
10
|
trigger: string[]
|
|
10
11
|
lang: string
|
|
11
12
|
verbose: boolean
|
|
13
|
+
assistantTranslation: AssistantTranslationMode
|
|
12
14
|
}
|
|
13
15
|
|
|
16
|
+
export type AssistantTranslationMode = "each-part" | "final-message"
|
|
17
|
+
|
|
14
18
|
export interface TranslateState {
|
|
15
19
|
translate_enabled: true
|
|
16
20
|
translate_user_lang: string
|
|
@@ -40,6 +44,7 @@ interface MessageLike {
|
|
|
40
44
|
id: string
|
|
41
45
|
sessionID: string
|
|
42
46
|
role: string
|
|
47
|
+
summary?: boolean
|
|
43
48
|
}
|
|
44
49
|
|
|
45
50
|
export interface TextPartLike {
|
|
@@ -67,6 +72,7 @@ export interface ProviderModelInfo {
|
|
|
67
72
|
}
|
|
68
73
|
headers?: Record<string, string>
|
|
69
74
|
options?: Record<string, unknown>
|
|
75
|
+
variants?: Record<string, Record<string, unknown>>
|
|
70
76
|
capabilities?: {
|
|
71
77
|
temperature?: boolean
|
|
72
78
|
}
|
|
@@ -154,4 +160,17 @@ export interface PluginClientLike {
|
|
|
154
160
|
}
|
|
155
161
|
}): Promise<unknown>
|
|
156
162
|
}
|
|
163
|
+
part?: {
|
|
164
|
+
update(
|
|
165
|
+
input: {
|
|
166
|
+
sessionID: string
|
|
167
|
+
messageID: string
|
|
168
|
+
partID: string
|
|
169
|
+
directory?: string
|
|
170
|
+
workspace?: string
|
|
171
|
+
part?: TextPartLike
|
|
172
|
+
},
|
|
173
|
+
options?: { throwOnError?: boolean },
|
|
174
|
+
): Promise<TextPartLike | SDKResponseLike<TextPartLike>>
|
|
175
|
+
}
|
|
157
176
|
}
|
package/src/translator/index.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { buildSystemPrompt, buildUserPrompt, unwrapEchoedTextEnvelope } from "..
|
|
|
13
13
|
import { __resetSyntheticPartIDForTest } from "./part-id"
|
|
14
14
|
import {
|
|
15
15
|
__resetProviderFactoryCacheForTest,
|
|
16
|
+
buildVariantProviderOptions,
|
|
16
17
|
instantiateModel,
|
|
17
18
|
instantiateProvider,
|
|
18
19
|
loadFactory,
|
|
@@ -73,6 +74,7 @@ export function createTranslator(
|
|
|
73
74
|
const { providerID, modelID } = parseTranslatorModel(options.model)
|
|
74
75
|
const credentials = await credentialResolver.resolve(options.model)
|
|
75
76
|
const modelInfo = resolveModelInfo(credentials.provider, modelID)
|
|
77
|
+
const variantProviderOptions = buildVariantProviderOptions(providerID, modelID, modelInfo, options.variant)
|
|
76
78
|
const factory = await loadFactory(providerID, modelInfo)
|
|
77
79
|
const provider = instantiateProvider(factory, providerID, credentials, modelInfo)
|
|
78
80
|
const providerOptions = { ...(credentials.provider?.options ?? {}), ...(modelInfo.options ?? {}) }
|
|
@@ -85,6 +87,7 @@ export function createTranslator(
|
|
|
85
87
|
model,
|
|
86
88
|
system: buildSystemPrompt(input),
|
|
87
89
|
...(supportsTemperature(providerID, modelID, modelInfo) ? { temperature: 0 } : {}),
|
|
90
|
+
...(variantProviderOptions ? { providerOptions: variantProviderOptions } : {}),
|
|
88
91
|
prompt: buildUserPrompt(input),
|
|
89
92
|
}),
|
|
90
93
|
timeoutMs,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type AuthInfo, type FetchLike, PLUGIN_NAME, type ProviderInfo, type ProviderModelInfo } from "../constants"
|
|
2
2
|
|
|
3
3
|
const providerFactoryCache = new Map<string, unknown>()
|
|
4
4
|
|
|
@@ -23,6 +23,26 @@ const CREATE_EXPORT_FALLBACK: Record<string, string[]> = {
|
|
|
23
23
|
"@openrouter/ai-sdk-provider": ["createOpenRouter", "openrouter"],
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
const PROVIDER_OPTIONS_KEY: Record<string, string> = {
|
|
27
|
+
"@ai-sdk/amazon-bedrock": "bedrock",
|
|
28
|
+
"@ai-sdk/amazon-bedrock/mantle": "openai",
|
|
29
|
+
"@ai-sdk/anthropic": "anthropic",
|
|
30
|
+
"@ai-sdk/azure": "openai",
|
|
31
|
+
"@ai-sdk/gateway": "gateway",
|
|
32
|
+
"@ai-sdk/github-copilot": "openai",
|
|
33
|
+
"@ai-sdk/google": "google",
|
|
34
|
+
"@ai-sdk/google-vertex": "vertex",
|
|
35
|
+
"@ai-sdk/google-vertex/anthropic": "anthropic",
|
|
36
|
+
"@ai-sdk/openai": "openai",
|
|
37
|
+
"@openrouter/ai-sdk-provider": "openrouter",
|
|
38
|
+
"ai-gateway-provider": "openaiCompatible",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
type JsonValue = null | string | number | boolean | JsonObject | JsonArray
|
|
42
|
+
type JsonObject = { [key: string]: JsonValue | undefined }
|
|
43
|
+
type JsonArray = JsonValue[]
|
|
44
|
+
type VariantProviderOptions = Record<string, JsonObject>
|
|
45
|
+
|
|
26
46
|
interface ProviderCredentials {
|
|
27
47
|
provider?: ProviderInfo
|
|
28
48
|
authInfo?: AuthInfo
|
|
@@ -73,6 +93,40 @@ export function resolveModelInfo(provider: ProviderInfo | undefined, modelID: st
|
|
|
73
93
|
return provider?.models?.[modelID] ?? { id: modelID, api: { id: modelID } }
|
|
74
94
|
}
|
|
75
95
|
|
|
96
|
+
function sdkProviderOptionsKey(providerID: string, model?: ProviderModelInfo): string {
|
|
97
|
+
const packageName = model?.api?.npm
|
|
98
|
+
if (packageName && PROVIDER_OPTIONS_KEY[packageName]) return PROVIDER_OPTIONS_KEY[packageName]
|
|
99
|
+
if (packageName === "@ai-sdk/openai-compatible" || packageName === "@ai-sdk/openai") return providerID.split(".")[0]
|
|
100
|
+
return providerID
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function invalidVariantError(providerID: string, modelID: string, model: ProviderModelInfo, variant: string) {
|
|
104
|
+
const variants = Object.keys(model.variants ?? {}).sort()
|
|
105
|
+
const modelName = `${providerID}/${modelID}`
|
|
106
|
+
if (variants.length === 0) {
|
|
107
|
+
return new Error(
|
|
108
|
+
`[${PLUGIN_NAME}:INVALID_VARIANT] options.variant "${variant}" is not available for "${modelName}". This model has no configurable variants.`,
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
return new Error(
|
|
112
|
+
`[${PLUGIN_NAME}:INVALID_VARIANT] options.variant "${variant}" is not available for "${modelName}". Available variants: ${variants.join(", ")}.`,
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function buildVariantProviderOptions(
|
|
117
|
+
providerID: string,
|
|
118
|
+
modelID: string,
|
|
119
|
+
model: ProviderModelInfo,
|
|
120
|
+
variant?: string,
|
|
121
|
+
): VariantProviderOptions | undefined {
|
|
122
|
+
if (!variant) return undefined
|
|
123
|
+
const selected = model.variants?.[variant]
|
|
124
|
+
if (!selected) throw invalidVariantError(providerID, modelID, model, variant)
|
|
125
|
+
const providerOptions = selected as JsonObject
|
|
126
|
+
if (model.api?.npm === "@ai-sdk/azure") return { openai: providerOptions, azure: providerOptions }
|
|
127
|
+
return { [sdkProviderOptionsKey(providerID, model)]: providerOptions }
|
|
128
|
+
}
|
|
129
|
+
|
|
76
130
|
function headerRecord(value: unknown): Record<string, string> {
|
|
77
131
|
if (!value || typeof value !== "object" || Array.isArray(value)) return {}
|
|
78
132
|
return Object.fromEntries(
|