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.
- package/package.json +6 -3
- package/src/activation/chat-message.ts +164 -0
- package/src/activation/index.ts +38 -0
- package/src/activation/logging.ts +11 -0
- package/src/activation/messages-transform.ts +38 -0
- package/src/activation/metadata.ts +42 -0
- package/src/activation/parts.ts +53 -0
- package/src/activation/question-hooks.ts +95 -0
- package/src/activation/state.ts +98 -0
- package/src/activation/text-complete.ts +51 -0
- package/src/activation/trigger.ts +57 -0
- package/src/activation/types.ts +41 -0
- package/src/activation.ts +1 -633
- package/src/anthropic-oauth.ts +3 -3
- package/src/auth/codex-request.ts +108 -0
- package/src/auth/codex-response.ts +78 -0
- package/src/auth/codex-shared.ts +3 -0
- package/src/auth/headers.ts +18 -0
- package/src/auth/index.ts +153 -0
- package/src/auth/oauth-fetch.ts +100 -0
- package/src/auth/refresh.ts +102 -0
- package/src/auth/retry.ts +70 -0
- package/src/auth/store.ts +45 -0
- package/src/auth/types.ts +27 -0
- package/src/auth.ts +1 -725
- package/src/constants/errors.ts +24 -0
- package/src/constants/guards.ts +34 -0
- package/src/constants/options.ts +37 -0
- package/src/constants/plugin.ts +10 -0
- package/src/constants/types.ts +147 -0
- package/src/constants.ts +5 -261
- package/src/labels.ts +0 -2
- package/src/translator/index.ts +125 -0
- package/src/translator/part-id.ts +43 -0
- package/src/translator/provider.ts +81 -0
- package/src/translator/retry.ts +62 -0
- package/src/translator/types.ts +17 -0
- package/src/translator.ts +1 -326
|
@@ -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
|
|
2
|
-
export
|
|
3
|
-
export
|
|
4
|
-
export
|
|
5
|
-
export
|
|
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
|
+
}
|