transduck 0.1.5 → 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.
@@ -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
+ }
@@ -1 +1,3 @@
1
+ 'use client';
2
+
1
3
  export { TransDuckProvider, t, ait, tPlural, aitPlural, useTransDuck, _resetReactState } from './provider.js';
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
  });
@@ -0,0 +1,289 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import {
3
+ buildMessages,
4
+ buildPluralMessages,
5
+ buildSinglePrompt,
6
+ buildPluralSinglePrompt,
7
+ safeRender,
8
+ } from '../src/providers/prompts.js';
9
+ import { getProvider } from '../src/providers/index.js';
10
+ import type { TransduckConfig } from '../src/config.js';
11
+ import { loadConfig } from '../src/config.js';
12
+ import { writeFileSync } from 'fs';
13
+ import { join } from 'path';
14
+ import { mkdtempSync } from 'fs';
15
+ import { tmpdir } from 'os';
16
+
17
+ function makeConfig(overrides: Partial<TransduckConfig> = {}): TransduckConfig {
18
+ return {
19
+ projectName: 'test',
20
+ projectContext: 'A test site',
21
+ sourceLang: 'EN',
22
+ targetLangs: ['DE'],
23
+ storagePath: '/tmp/test.duckdb',
24
+ provider: 'openai',
25
+ apiKeyEnv: 'OPENAI_API_KEY',
26
+ tokenEnv: 'CLAUDE_CODE_OAUTH_TOKEN',
27
+ backendModel: 'gpt-4.1-mini',
28
+ backendTimeout: 10,
29
+ backendMaxRetries: 2,
30
+ readOnly: false,
31
+ ...overrides,
32
+ };
33
+ }
34
+
35
+ // ---- Prompts ----
36
+
37
+ describe('prompts', () => {
38
+ describe('buildMessages', () => {
39
+ it('builds system and user messages', () => {
40
+ const messages = buildMessages({
41
+ sourceText: 'Hello',
42
+ sourceLang: 'EN',
43
+ targetLang: 'DE',
44
+ projectContext: 'A travel site',
45
+ stringContext: 'greeting',
46
+ });
47
+ expect(messages).toHaveLength(2);
48
+ expect(messages[0].role).toBe('system');
49
+ expect(messages[0].content).toContain('EN');
50
+ expect(messages[0].content).toContain('DE');
51
+ expect(messages[1].role).toBe('user');
52
+ expect(messages[1].content).toContain('Hello');
53
+ expect(messages[1].content).toContain('greeting');
54
+ });
55
+
56
+ it('uses "none" for null string context', () => {
57
+ const messages = buildMessages({
58
+ sourceText: 'Hello',
59
+ sourceLang: 'EN',
60
+ targetLang: 'DE',
61
+ projectContext: 'test',
62
+ stringContext: null,
63
+ });
64
+ expect(messages[1].content).toContain('none');
65
+ });
66
+ });
67
+
68
+ describe('buildPluralMessages', () => {
69
+ it('builds plural messages with CLDR info', () => {
70
+ const messages = buildPluralMessages({
71
+ one: '{count} item',
72
+ other: '{count} items',
73
+ sourceLang: 'EN',
74
+ targetLang: 'DE',
75
+ projectContext: 'test',
76
+ stringContext: null,
77
+ });
78
+ expect(messages).toHaveLength(2);
79
+ expect(messages[0].content).toContain('CLDR');
80
+ expect(messages[1].content).toContain('{count} item');
81
+ expect(messages[1].content).toContain('{count} items');
82
+ });
83
+ });
84
+
85
+ describe('buildSinglePrompt', () => {
86
+ it('combines system and user into one string', () => {
87
+ const prompt = buildSinglePrompt({
88
+ sourceText: 'Hello',
89
+ sourceLang: 'EN',
90
+ targetLang: 'DE',
91
+ projectContext: 'A travel site',
92
+ stringContext: 'greeting',
93
+ });
94
+ expect(prompt).toContain('EN');
95
+ expect(prompt).toContain('DE');
96
+ expect(prompt).toContain('Hello');
97
+ expect(prompt).toContain('greeting');
98
+ });
99
+ });
100
+
101
+ describe('buildPluralSinglePrompt', () => {
102
+ it('combines plural system and user into one string', () => {
103
+ const prompt = buildPluralSinglePrompt({
104
+ one: '{count} item',
105
+ other: '{count} items',
106
+ sourceLang: 'EN',
107
+ targetLang: 'DE',
108
+ projectContext: 'test',
109
+ stringContext: null,
110
+ });
111
+ expect(prompt).toContain('CLDR');
112
+ expect(prompt).toContain('{count} item');
113
+ });
114
+ });
115
+
116
+ describe('safeRender', () => {
117
+ it('replaces known keys only', () => {
118
+ const result = safeRender('Hello {name}, you have {count} items', {
119
+ name: 'Tim',
120
+ });
121
+ expect(result).toBe('Hello Tim, you have {count} items');
122
+ });
123
+ });
124
+ });
125
+
126
+ // ---- Provider router ----
127
+
128
+ describe('getProvider', () => {
129
+ it('returns openai provider for openai config', async () => {
130
+ const openaiProvider = await import('../src/providers/openai-provider.js');
131
+ const provider = await getProvider(makeConfig({ provider: 'openai' }));
132
+ expect(provider.translate).toBe(openaiProvider.translate);
133
+ expect(provider.translatePlural).toBe(openaiProvider.translatePlural);
134
+ });
135
+
136
+ it('returns claude_api provider for claude_api config', async () => {
137
+ const claudeApiProvider = await import('../src/providers/claude-api.js');
138
+ const provider = await getProvider(makeConfig({ provider: 'claude_api' }));
139
+ expect(provider.translate).toBe(claudeApiProvider.translate);
140
+ });
141
+
142
+ it('returns claude_code provider for claude_code config', async () => {
143
+ const claudeCodeProvider = await import('../src/providers/claude-code.js');
144
+ const provider = await getProvider(makeConfig({ provider: 'claude_code' }));
145
+ expect(provider.translate).toBe(claudeCodeProvider.translate);
146
+ });
147
+
148
+ it('throws for invalid provider', async () => {
149
+ await expect(
150
+ getProvider(makeConfig({ provider: 'invalid' }))
151
+ ).rejects.toThrow('Unknown provider');
152
+ });
153
+ });
154
+
155
+ // ---- OpenAI provider ----
156
+
157
+ describe('openai-provider', () => {
158
+ it('translate calls OpenAI and returns result', async () => {
159
+ const { translate } = await import('../src/providers/openai-provider.js');
160
+ const mockCreate = vi.fn().mockResolvedValue({
161
+ choices: [{ message: { content: 'Hallo' } }],
162
+ });
163
+
164
+ const cfg = makeConfig();
165
+ process.env.OPENAI_API_KEY = 'test-key';
166
+
167
+ const result = await translate(
168
+ 'Hello', 'EN', 'DE', 'test', null, cfg,
169
+ { chat: { completions: { create: mockCreate } } } as any,
170
+ );
171
+ expect(result).toBe('Hallo');
172
+ expect(mockCreate).toHaveBeenCalledOnce();
173
+ });
174
+
175
+ it('translatePlural calls OpenAI and returns parsed JSON', async () => {
176
+ const { translatePlural } = await import('../src/providers/openai-provider.js');
177
+ const mockCreate = vi.fn().mockResolvedValue({
178
+ choices: [{ message: { content: '{"one": "{count} Nachricht", "other": "{count} Nachrichten"}' } }],
179
+ });
180
+
181
+ const cfg = makeConfig();
182
+ process.env.OPENAI_API_KEY = 'test-key';
183
+
184
+ const result = await translatePlural(
185
+ '{count} message', '{count} messages',
186
+ 'EN', 'DE', 'test', null, cfg,
187
+ { chat: { completions: { create: mockCreate } } } as any,
188
+ );
189
+ expect(result).toEqual({
190
+ one: '{count} Nachricht',
191
+ other: '{count} Nachrichten',
192
+ });
193
+ });
194
+ });
195
+
196
+ // ---- Config with provider fields ----
197
+
198
+ describe('config provider fields', () => {
199
+ it('defaults to openai provider', () => {
200
+ const tmpDir = mkdtempSync(join(tmpdir(), 'transduck-cfg-'));
201
+ const configPath = join(tmpDir, 'transduck.yaml');
202
+ writeFileSync(configPath, `
203
+ project:
204
+ name: test
205
+ context: "test"
206
+ languages:
207
+ source: EN
208
+ targets: [DE]
209
+ storage:
210
+ path: ./translations.duckdb
211
+ backend:
212
+ api_key_env: OPENAI_API_KEY
213
+ model: gpt-4.1-mini
214
+ timeout_seconds: 10
215
+ max_retries: 2
216
+ `);
217
+ const cfg = loadConfig(configPath);
218
+ expect(cfg.provider).toBe('openai');
219
+ expect(cfg.tokenEnv).toBe('CLAUDE_CODE_OAUTH_TOKEN');
220
+ });
221
+
222
+ it('loads claude_api provider', () => {
223
+ const tmpDir = mkdtempSync(join(tmpdir(), 'transduck-cfg-'));
224
+ const configPath = join(tmpDir, 'transduck.yaml');
225
+ writeFileSync(configPath, `
226
+ project:
227
+ name: test
228
+ context: "test"
229
+ languages:
230
+ source: EN
231
+ targets: [DE]
232
+ storage:
233
+ path: ./translations.duckdb
234
+ backend:
235
+ provider: claude_api
236
+ api_key_env: ANTHROPIC_API_KEY
237
+ model: claude-haiku-4-5-20251001
238
+ timeout_seconds: 15
239
+ max_retries: 3
240
+ `);
241
+ const cfg = loadConfig(configPath);
242
+ expect(cfg.provider).toBe('claude_api');
243
+ expect(cfg.apiKeyEnv).toBe('ANTHROPIC_API_KEY');
244
+ expect(cfg.backendModel).toBe('claude-haiku-4-5-20251001');
245
+ });
246
+
247
+ it('claude_code forces readOnly', () => {
248
+ const tmpDir = mkdtempSync(join(tmpdir(), 'transduck-cfg-'));
249
+ const configPath = join(tmpDir, 'transduck.yaml');
250
+ writeFileSync(configPath, `
251
+ project:
252
+ name: test
253
+ context: "test"
254
+ languages:
255
+ source: EN
256
+ targets: [DE]
257
+ storage:
258
+ path: ./translations.duckdb
259
+ backend:
260
+ provider: claude_code
261
+ token_env: CLAUDE_CODE_OAUTH_TOKEN
262
+ `);
263
+ const cfg = loadConfig(configPath);
264
+ expect(cfg.provider).toBe('claude_code');
265
+ expect(cfg.tokenEnv).toBe('CLAUDE_CODE_OAUTH_TOKEN');
266
+ expect(cfg.readOnly).toBe(true);
267
+ });
268
+
269
+ it('claude_code minimal config uses defaults', () => {
270
+ const tmpDir = mkdtempSync(join(tmpdir(), 'transduck-cfg-'));
271
+ const configPath = join(tmpDir, 'transduck.yaml');
272
+ writeFileSync(configPath, `
273
+ project:
274
+ name: test
275
+ context: "test"
276
+ languages:
277
+ source: EN
278
+ targets: [DE]
279
+ storage:
280
+ path: ./translations.duckdb
281
+ backend:
282
+ provider: claude_code
283
+ `);
284
+ const cfg = loadConfig(configPath);
285
+ expect(cfg.provider).toBe('claude_code');
286
+ expect(cfg.readOnly).toBe(true);
287
+ expect(cfg.backendModel).toBe('gpt-4.1-mini');
288
+ });
289
+ });