transduck 0.1.5 → 0.2.1

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/src/handler.ts CHANGED
@@ -58,7 +58,6 @@ export async function handleTranslationRequest(
58
58
  const targetLang = body.language.toUpperCase();
59
59
 
60
60
  const projectContextHash = hash(cfg.projectContext);
61
- const apiKey = process.env[cfg.apiKeyEnv];
62
61
 
63
62
  const translations: Record<string, string> = {};
64
63
  const plurals: Record<string, Record<string, string>> = {};
@@ -84,17 +83,14 @@ export async function handleTranslationRequest(
84
83
 
85
84
  // Backend call
86
85
  try {
87
- const translated = await backendTranslate({
88
- sourceText: item.text,
89
- sourceLang: cfg.sourceLang,
86
+ const translated = await backendTranslate(
87
+ item.text,
88
+ cfg.sourceLang,
90
89
  targetLang,
91
- projectContext: cfg.projectContext,
92
- stringContext: item.context ?? null,
93
- apiKey: apiKey!,
94
- model: cfg.backendModel,
95
- timeout: cfg.backendTimeout,
96
- maxRetries: cfg.backendMaxRetries,
97
- });
90
+ cfg.projectContext,
91
+ item.context ?? null,
92
+ cfg,
93
+ );
98
94
 
99
95
  if (validateTranslation(item.text, translated)) {
100
96
  await store.insert({
@@ -138,18 +134,15 @@ export async function handleTranslationRequest(
138
134
 
139
135
  // Backend call
140
136
  try {
141
- const forms = await backendTranslatePlural({
142
- one: item.one,
143
- other: item.other,
144
- sourceLang: cfg.sourceLang,
137
+ const forms = await backendTranslatePlural(
138
+ item.one,
139
+ item.other,
140
+ cfg.sourceLang,
145
141
  targetLang,
146
- projectContext: cfg.projectContext,
147
- stringContext: item.context ?? null,
148
- apiKey: apiKey!,
149
- model: cfg.backendModel,
150
- timeout: cfg.backendTimeout,
151
- maxRetries: cfg.backendMaxRetries,
152
- });
142
+ cfg.projectContext,
143
+ item.context ?? null,
144
+ cfg,
145
+ );
153
146
 
154
147
  for (const [cat, translatedText] of Object.entries(forms)) {
155
148
  await store.insertPlural({
package/src/index.ts CHANGED
@@ -92,14 +92,11 @@ export async function ait(
92
92
  });
93
93
  if (rechecked !== null) return rechecked;
94
94
 
95
- const apiKey = process.env[cfg.apiKeyEnv];
96
95
  try {
97
- const translated = await backendTranslate({
98
- sourceText, sourceLang: cfg.sourceLang, targetLang: state.targetLang!,
99
- projectContext: cfg.projectContext, stringContext: context ?? null,
100
- apiKey: apiKey!, model: cfg.backendModel,
101
- timeout: cfg.backendTimeout, maxRetries: cfg.backendMaxRetries,
102
- });
96
+ const translated = await backendTranslate(
97
+ sourceText, cfg.sourceLang, state.targetLang!,
98
+ cfg.projectContext, context ?? null, cfg,
99
+ );
103
100
 
104
101
  if (!validateTranslation(sourceText, translated)) {
105
102
  console.warn(`[transduck] Validation failed for: ${sourceText} -> ${translated}`);
@@ -192,15 +189,12 @@ export async function aitPlural(
192
189
  }
193
190
 
194
191
  // Cache miss — call backend
195
- const apiKey = process.env[cfg.apiKeyEnv];
196
192
  try {
197
- const forms = await backendTranslatePlural({
193
+ const forms = await backendTranslatePlural(
198
194
  one, other,
199
- sourceLang: cfg.sourceLang, targetLang: state.targetLang,
200
- projectContext: cfg.projectContext, stringContext: context ?? null,
201
- apiKey: apiKey!, model: cfg.backendModel,
202
- timeout: cfg.backendTimeout, maxRetries: cfg.backendMaxRetries,
203
- });
195
+ cfg.sourceLang, state.targetLang,
196
+ cfg.projectContext, context ?? null, cfg,
197
+ );
204
198
 
205
199
  // Validate and store each form
206
200
  const validCategories = new Set(['zero', 'one', 'two', 'few', 'many', 'other']);
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Claude API translation provider.
3
+ * Uses the @anthropic-ai/sdk package (optional peer dependency, lazy imported).
4
+ */
5
+
6
+ import type { TransduckConfig } from '../config.js';
7
+ import { buildMessages, buildPluralMessages } from './prompts.js';
8
+
9
+ async function getClient(config: TransduckConfig) {
10
+ let Anthropic: any;
11
+ try {
12
+ // @ts-ignore — optional peer dependency, may not be installed
13
+ const mod = await import('@anthropic-ai/sdk');
14
+ Anthropic = mod.default ?? mod.Anthropic;
15
+ } catch {
16
+ throw new Error(
17
+ 'Install the required package: npm install @anthropic-ai/sdk'
18
+ );
19
+ }
20
+
21
+ const apiKey = process.env[config.apiKeyEnv];
22
+ if (!apiKey) {
23
+ throw new Error(`Set ${config.apiKeyEnv} environment variable`);
24
+ }
25
+
26
+ return new Anthropic({ apiKey });
27
+ }
28
+
29
+ export async function translate(
30
+ sourceText: string,
31
+ sourceLang: string,
32
+ targetLang: string,
33
+ projectContext: string,
34
+ stringContext: string | null,
35
+ config: TransduckConfig,
36
+ ): Promise<string> {
37
+ const client = await getClient(config);
38
+ const messages = buildMessages({
39
+ sourceText, sourceLang, targetLang, projectContext, stringContext,
40
+ });
41
+
42
+ const response = await client.messages.create({
43
+ model: config.backendModel,
44
+ max_tokens: 1024,
45
+ temperature: 0.3,
46
+ system: messages[0].content,
47
+ messages: [{ role: 'user', content: messages[1].content }],
48
+ });
49
+
50
+ return response.content[0].text.trim();
51
+ }
52
+
53
+ export async function translatePlural(
54
+ one: string,
55
+ other: string,
56
+ sourceLang: string,
57
+ targetLang: string,
58
+ projectContext: string,
59
+ stringContext: string | null,
60
+ config: TransduckConfig,
61
+ ): Promise<Record<string, string>> {
62
+ const client = await getClient(config);
63
+ const messages = buildPluralMessages({
64
+ one, other, sourceLang, targetLang, projectContext, stringContext,
65
+ });
66
+
67
+ const response = await client.messages.create({
68
+ model: config.backendModel,
69
+ max_tokens: 2048,
70
+ temperature: 0.3,
71
+ system: messages[0].content,
72
+ messages: [{ role: 'user', content: messages[1].content }],
73
+ });
74
+
75
+ const raw = response.content[0].text.trim();
76
+ return JSON.parse(raw);
77
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Claude Code translation provider (uses Claude Agent SDK with subscription auth).
3
+ * Uses the @anthropic-ai/claude-agent-sdk package (optional peer dependency, lazy imported).
4
+ * Already async in JS — no asyncio bridge needed like Python.
5
+ */
6
+
7
+ import type { TransduckConfig } from '../config.js';
8
+ import { buildSinglePrompt, buildPluralSinglePrompt } from './prompts.js';
9
+
10
+ function ensureToken(config: TransduckConfig): void {
11
+ const token = process.env[config.tokenEnv];
12
+ if (!token) {
13
+ throw new Error(
14
+ `Set ${config.tokenEnv} \u2014 run 'claude setup-token' to get your OAuth token`
15
+ );
16
+ }
17
+ }
18
+
19
+ async function translateWithSdk(prompt: string): Promise<string> {
20
+ let query: any;
21
+ try {
22
+ // @ts-ignore — optional peer dependency, may not be installed
23
+ const mod = await import('@anthropic-ai/claude-agent-sdk');
24
+ query = mod.query;
25
+ } catch {
26
+ throw new Error(
27
+ 'Install the required package: npm install @anthropic-ai/claude-agent-sdk'
28
+ );
29
+ }
30
+
31
+ for await (const message of query({
32
+ prompt,
33
+ options: { allowedTools: [] },
34
+ })) {
35
+ if ('result' in message) {
36
+ return (message as any).result.trim();
37
+ }
38
+ }
39
+ throw new Error('No result from Claude Agent SDK');
40
+ }
41
+
42
+ export async function translate(
43
+ sourceText: string,
44
+ sourceLang: string,
45
+ targetLang: string,
46
+ projectContext: string,
47
+ stringContext: string | null,
48
+ config: TransduckConfig,
49
+ ): Promise<string> {
50
+ ensureToken(config);
51
+ const prompt = buildSinglePrompt({
52
+ sourceText, sourceLang, targetLang, projectContext, stringContext,
53
+ });
54
+ return translateWithSdk(prompt);
55
+ }
56
+
57
+ export async function translatePlural(
58
+ one: string,
59
+ other: string,
60
+ sourceLang: string,
61
+ targetLang: string,
62
+ projectContext: string,
63
+ stringContext: string | null,
64
+ config: TransduckConfig,
65
+ ): Promise<Record<string, string>> {
66
+ ensureToken(config);
67
+ const prompt = buildPluralSinglePrompt({
68
+ one, other, sourceLang, targetLang, projectContext, stringContext,
69
+ });
70
+ const raw = await translateWithSdk(prompt);
71
+ return JSON.parse(raw);
72
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Translation provider abstraction.
3
+ */
4
+
5
+ import type { TransduckConfig } from '../config.js';
6
+
7
+ export interface TranslationProvider {
8
+ translate(
9
+ sourceText: string,
10
+ sourceLang: string,
11
+ targetLang: string,
12
+ projectContext: string,
13
+ stringContext: string | null,
14
+ config: TransduckConfig,
15
+ _clientOverride?: any,
16
+ ): Promise<string>;
17
+
18
+ translatePlural(
19
+ one: string,
20
+ other: string,
21
+ sourceLang: string,
22
+ targetLang: string,
23
+ projectContext: string,
24
+ stringContext: string | null,
25
+ config: TransduckConfig,
26
+ _clientOverride?: any,
27
+ ): Promise<Record<string, string>>;
28
+ }
29
+
30
+ /**
31
+ * Return the provider module for the configured provider.
32
+ */
33
+ export async function getProvider(config: TransduckConfig): Promise<TranslationProvider> {
34
+ if (config.provider === 'openai') {
35
+ return await import('./openai-provider.js');
36
+ } else if (config.provider === 'claude_api') {
37
+ return await import('./claude-api.js');
38
+ } else if (config.provider === 'claude_code') {
39
+ return await import('./claude-code.js');
40
+ } else {
41
+ throw new Error(
42
+ `Unknown provider: ${config.provider}. Valid: openai, claude_api, claude_code`
43
+ );
44
+ }
45
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * OpenAI translation provider.
3
+ */
4
+
5
+ import OpenAI from 'openai';
6
+ import type { TransduckConfig } from '../config.js';
7
+ import { buildMessages, buildPluralMessages } from './prompts.js';
8
+
9
+ export async function translate(
10
+ sourceText: string,
11
+ sourceLang: string,
12
+ targetLang: string,
13
+ projectContext: string,
14
+ stringContext: string | null,
15
+ config: TransduckConfig,
16
+ _clientOverride?: any,
17
+ ): Promise<string> {
18
+ const apiKey = process.env[config.apiKeyEnv];
19
+ const client = _clientOverride ?? new OpenAI({
20
+ apiKey,
21
+ timeout: config.backendTimeout * 1000,
22
+ maxRetries: config.backendMaxRetries,
23
+ });
24
+
25
+ const messages = buildMessages({
26
+ sourceText, sourceLang, targetLang, projectContext, stringContext,
27
+ });
28
+
29
+ const response = await client.chat.completions.create({
30
+ model: config.backendModel,
31
+ messages,
32
+ temperature: 0.3,
33
+ });
34
+
35
+ return response.choices[0].message.content.trim();
36
+ }
37
+
38
+ export async function translatePlural(
39
+ one: string,
40
+ other: string,
41
+ sourceLang: string,
42
+ targetLang: string,
43
+ projectContext: string,
44
+ stringContext: string | null,
45
+ config: TransduckConfig,
46
+ _clientOverride?: any,
47
+ ): Promise<Record<string, string>> {
48
+ const apiKey = process.env[config.apiKeyEnv];
49
+ const client = _clientOverride ?? new OpenAI({
50
+ apiKey,
51
+ timeout: config.backendTimeout * 1000,
52
+ maxRetries: config.backendMaxRetries,
53
+ });
54
+
55
+ const messages = buildPluralMessages({
56
+ one, other, sourceLang, targetLang, projectContext, stringContext,
57
+ });
58
+
59
+ const response = await client.chat.completions.create({
60
+ model: config.backendModel,
61
+ messages,
62
+ temperature: 0.3,
63
+ });
64
+
65
+ const raw = response.choices[0].message.content.trim();
66
+ return JSON.parse(raw);
67
+ }
@@ -0,0 +1,124 @@
1
+ /* eslint-disable no-template-curly-in-string */
2
+
3
+ /**
4
+ * Shared prompt templates for all translation providers.
5
+ *
6
+ * IMPORTANT: Uses string concatenation (NOT template literals) because the
7
+ * prompt text contains ${value} patterns that must be preserved literally.
8
+ */
9
+
10
+ const SYSTEM_TEMPLATE =
11
+ 'You are a professional translator. Translate the given text from {source_lang} ' +
12
+ 'to {target_lang}. Return ONLY the translated text, nothing else. Preserve any ' +
13
+ 'placeholders like {name}, {{count}}, %s, ${value} exactly as they appear. ' +
14
+ 'Preserve brand names. Match the tone and formality of the original.\n\n' +
15
+ 'Project context: {project_context}';
16
+
17
+ const USER_TEMPLATE =
18
+ 'Translate: "{source_text}"\nString context: {string_context}';
19
+
20
+ const PLURAL_SYSTEM_TEMPLATE =
21
+ 'You are a professional translator. You will be given two plural forms ' +
22
+ '(one and other) in {source_lang}. Generate ALL plural forms needed in ' +
23
+ '{target_lang} according to CLDR plural rules. Return ONLY a JSON object ' +
24
+ 'mapping plural categories to translated strings. Do not include explanation.\n\n' +
25
+ 'CRITICAL: Placeholders like {count}, {name}, %s, ${value} are SOFTWARE VARIABLES ' +
26
+ 'that will be replaced by code at runtime. You MUST include them exactly as written ' +
27
+ 'in EVERY plural form, even for zero, one, and two categories where the language ' +
28
+ 'would not normally use a numeral. For example, for Arabic zero: use "{count} ..." ' +
29
+ 'not "\u0644\u0627 \u062a\u0648\u062c\u062f ...". For Arabic one: use "{count} ..." not "\u0648\u0627\u062d\u062f\u0629 ...".\n\n' +
30
+ 'Preserve brand names. Match the tone and formality of the original.\n\n' +
31
+ 'CLDR plural categories are: zero, one, two, few, many, other.\n' +
32
+ 'Only include categories that {target_lang} actually uses.\n\n' +
33
+ 'Project context: {project_context}';
34
+
35
+ const PLURAL_USER_TEMPLATE =
36
+ 'Source one form: "{one}"\n' +
37
+ 'Source other form: "{other}"\n' +
38
+ 'String context: {string_context}';
39
+
40
+ /**
41
+ * Safe string replacement that only replaces known keys.
42
+ * Does NOT use str.replace with regex — avoids issues with ${value} patterns.
43
+ */
44
+ export function safeRender(template: string, vars: Record<string, string>): string {
45
+ let result = template;
46
+ for (const [key, value] of Object.entries(vars)) {
47
+ result = result.replaceAll('{' + key + '}', value);
48
+ }
49
+ return result;
50
+ }
51
+
52
+ export interface BuildMessagesParams {
53
+ sourceText: string;
54
+ sourceLang: string;
55
+ targetLang: string;
56
+ projectContext: string;
57
+ stringContext: string | null;
58
+ }
59
+
60
+ export interface BuildPluralMessagesParams {
61
+ one: string;
62
+ other: string;
63
+ sourceLang: string;
64
+ targetLang: string;
65
+ projectContext: string;
66
+ stringContext: string | null;
67
+ }
68
+
69
+ /**
70
+ * Build system + user messages for chat-based providers (OpenAI, Claude API).
71
+ */
72
+ export function buildMessages(params: BuildMessagesParams): Array<{ role: string; content: string }> {
73
+ const systemMsg = SYSTEM_TEMPLATE
74
+ .replace('{source_lang}', params.sourceLang)
75
+ .replace('{target_lang}', params.targetLang)
76
+ .replace('{project_context}', params.projectContext);
77
+
78
+ const userMsg = USER_TEMPLATE
79
+ .replace('{source_text}', params.sourceText)
80
+ .replace('{string_context}', params.stringContext || 'none');
81
+
82
+ return [
83
+ { role: 'system', content: systemMsg },
84
+ { role: 'user', content: userMsg },
85
+ ];
86
+ }
87
+
88
+ /**
89
+ * Build system + user messages for plural chat-based providers.
90
+ */
91
+ export function buildPluralMessages(params: BuildPluralMessagesParams): Array<{ role: string; content: string }> {
92
+ const systemMsg = safeRender(PLURAL_SYSTEM_TEMPLATE, {
93
+ source_lang: params.sourceLang,
94
+ target_lang: params.targetLang,
95
+ project_context: params.projectContext,
96
+ });
97
+
98
+ const userMsg = safeRender(PLURAL_USER_TEMPLATE, {
99
+ one: params.one,
100
+ other: params.other,
101
+ string_context: params.stringContext || 'none',
102
+ });
103
+
104
+ return [
105
+ { role: 'system', content: systemMsg },
106
+ { role: 'user', content: userMsg },
107
+ ];
108
+ }
109
+
110
+ /**
111
+ * Build a single combined prompt for non-chat providers (Claude Code).
112
+ */
113
+ export function buildSinglePrompt(params: BuildMessagesParams): string {
114
+ const messages = buildMessages(params);
115
+ return messages[0].content + '\n\n' + messages[1].content;
116
+ }
117
+
118
+ /**
119
+ * Build a single combined plural prompt for non-chat providers.
120
+ */
121
+ export function buildPluralSinglePrompt(params: BuildPluralMessagesParams): string {
122
+ const messages = buildPluralMessages(params);
123
+ return messages[0].content + '\n\n' + messages[1].content;
124
+ }
package/tests/ait.test.ts CHANGED
@@ -16,10 +16,13 @@ function makeConfig(tmpDir: string): TransduckConfig {
16
16
  sourceLang: 'EN',
17
17
  targetLangs: ['DE', 'ES'],
18
18
  storagePath: join(tmpDir, 'test.duckdb'),
19
+ provider: 'openai',
19
20
  apiKeyEnv: 'OPENAI_API_KEY',
21
+ tokenEnv: 'CLAUDE_CODE_OAUTH_TOKEN',
20
22
  backendModel: 'gpt-4.1-mini',
21
23
  backendTimeout: 10,
22
24
  backendMaxRetries: 2,
25
+ readOnly: false,
23
26
  };
24
27
  }
25
28
 
@@ -1,5 +1,23 @@
1
1
  import { describe, it, expect, vi } from 'vitest';
2
- import { buildMessages, translate, buildPluralMessages, translatePlural } from '../src/backend.js';
2
+ import { buildMessages, buildPluralMessages, translate, translatePlural } from '../src/backend.js';
3
+ import type { TransduckConfig } from '../src/config.js';
4
+
5
+ function makeConfig(): TransduckConfig {
6
+ return {
7
+ projectName: 'test',
8
+ projectContext: 'A travel site about Mallorca',
9
+ sourceLang: 'EN',
10
+ targetLangs: ['DE'],
11
+ storagePath: '/tmp/test.duckdb',
12
+ provider: 'openai',
13
+ apiKeyEnv: 'OPENAI_API_KEY',
14
+ tokenEnv: 'CLAUDE_CODE_OAUTH_TOKEN',
15
+ backendModel: 'gpt-4.1-mini',
16
+ backendTimeout: 10,
17
+ backendMaxRetries: 2,
18
+ readOnly: false,
19
+ };
20
+ }
3
21
 
4
22
  describe('buildMessages', () => {
5
23
  it('builds system and user messages', () => {
@@ -38,18 +56,14 @@ describe('translate', () => {
38
56
  choices: [{ message: { content: 'Unsere Veranstaltungen' } }],
39
57
  });
40
58
 
41
- const result = await translate({
42
- sourceText: 'Our Events',
43
- sourceLang: 'EN',
44
- targetLang: 'DE',
45
- projectContext: 'A travel site',
46
- stringContext: null,
47
- apiKey: 'test-key',
48
- model: 'gpt-4.1-mini',
49
- timeout: 10,
50
- maxRetries: 2,
51
- _clientOverride: { chat: { completions: { create: mockCreate } } } as any,
52
- });
59
+ const cfg = makeConfig();
60
+ process.env.OPENAI_API_KEY = 'test-key';
61
+
62
+ const result = await translate(
63
+ 'Our Events', 'EN', 'DE',
64
+ 'A travel site', null, cfg,
65
+ { chat: { completions: { create: mockCreate } } } as any,
66
+ );
53
67
  expect(result).toBe('Unsere Veranstaltungen');
54
68
  });
55
69
 
@@ -58,18 +72,14 @@ describe('translate', () => {
58
72
  choices: [{ message: { content: ' Hallo \n' } }],
59
73
  });
60
74
 
61
- const result = await translate({
62
- sourceText: 'Hello',
63
- sourceLang: 'EN',
64
- targetLang: 'DE',
65
- projectContext: 'test',
66
- stringContext: null,
67
- apiKey: 'test-key',
68
- model: 'gpt-4.1-mini',
69
- timeout: 10,
70
- maxRetries: 2,
71
- _clientOverride: { chat: { completions: { create: mockCreate } } } as any,
72
- });
75
+ const cfg = makeConfig();
76
+ process.env.OPENAI_API_KEY = 'test-key';
77
+
78
+ const result = await translate(
79
+ 'Hello', 'EN', 'DE',
80
+ 'test', null, cfg,
81
+ { chat: { completions: { create: mockCreate } } } as any,
82
+ );
73
83
  expect(result).toBe('Hallo');
74
84
  });
75
85
  });
@@ -132,19 +142,15 @@ describe('translatePlural', () => {
132
142
  choices: [{ message: { content: '{"one": "{count} Nachricht", "other": "{count} Nachrichten"}' } }],
133
143
  });
134
144
 
135
- const result = await translatePlural({
136
- one: '{count} message',
137
- other: '{count} messages',
138
- sourceLang: 'EN',
139
- targetLang: 'DE',
140
- projectContext: 'test',
141
- stringContext: null,
142
- apiKey: 'test-key',
143
- model: 'gpt-4.1-mini',
144
- timeout: 10,
145
- maxRetries: 2,
146
- _clientOverride: { chat: { completions: { create: mockCreate } } } as any,
147
- });
145
+ const cfg = makeConfig();
146
+ process.env.OPENAI_API_KEY = 'test-key';
147
+
148
+ const result = await translatePlural(
149
+ '{count} message', '{count} messages',
150
+ 'EN', 'DE',
151
+ 'test', null, cfg,
152
+ { chat: { completions: { create: mockCreate } } } as any,
153
+ );
148
154
 
149
155
  expect(result).toEqual({
150
156
  one: '{count} Nachricht',
@@ -155,30 +161,26 @@ describe('translatePlural', () => {
155
161
  it('returns multiple categories for Russian', async () => {
156
162
  const mockCreate = vi.fn().mockResolvedValue({
157
163
  choices: [{ message: { content: JSON.stringify({
158
- one: '{count} сообщение',
159
- few: '{count} сообщения',
160
- many: '{count} сообщений',
161
- other: '{count} сообщений',
164
+ one: '{count} \u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0435',
165
+ few: '{count} \u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u044f',
166
+ many: '{count} \u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0439',
167
+ other: '{count} \u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0439',
162
168
  }) } }],
163
169
  });
164
170
 
165
- const result = await translatePlural({
166
- one: '{count} message',
167
- other: '{count} messages',
168
- sourceLang: 'EN',
169
- targetLang: 'RU',
170
- projectContext: 'test',
171
- stringContext: null,
172
- apiKey: 'test-key',
173
- model: 'gpt-4.1-mini',
174
- timeout: 10,
175
- maxRetries: 2,
176
- _clientOverride: { chat: { completions: { create: mockCreate } } } as any,
177
- });
171
+ const cfg = makeConfig();
172
+ process.env.OPENAI_API_KEY = 'test-key';
173
+
174
+ const result = await translatePlural(
175
+ '{count} message', '{count} messages',
176
+ 'EN', 'RU',
177
+ 'test', null, cfg,
178
+ { chat: { completions: { create: mockCreate } } } as any,
179
+ );
178
180
 
179
- expect(result.one).toBe('{count} сообщение');
180
- expect(result.few).toBe('{count} сообщения');
181
- expect(result.many).toBe('{count} сообщений');
182
- expect(result.other).toBe('{count} сообщений');
181
+ expect(result.one).toBe('{count} \u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0435');
182
+ expect(result.few).toBe('{count} \u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u044f');
183
+ expect(result.many).toBe('{count} \u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0439');
184
+ expect(result.other).toBe('{count} \u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0439');
183
185
  });
184
186
  });