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 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
- "lang": "Korean" // language you speak
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-translate",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "description": "OpenCode plugin that lets the user chat in a configured language while the main chat loop only sees English.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -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
+ }
@@ -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
- return {
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
@@ -36,6 +36,7 @@ export interface HookDependencies {
36
36
  export interface HookContext {
37
37
  client: PluginClientLike
38
38
  directory?: string
39
+ serverUrl?: URL
39
40
  options: ResolvedTranslateOptions
40
41
  translator: TranslatorLike
41
42
  }
@@ -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
 
@@ -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
  }
@@ -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 { AuthInfo, FetchLike, ProviderInfo, ProviderModelInfo } from "../constants"
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(