dialekt 0.1.0
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 +62 -0
- package/TESTING.md +66 -0
- package/dist/cli/main.d.mts +1 -0
- package/dist/cli/main.mjs +412 -0
- package/dist/formatters-De4Q-X1d.mjs +577 -0
- package/dist/index.d.mts +329 -0
- package/dist/index.mjs +60 -0
- package/package.json +39 -0
- package/pnpm-workspace.yaml +7 -0
- package/src/adapter/types.test.ts +98 -0
- package/src/adapter/types.ts +73 -0
- package/src/benchmark/metrics.test.ts +180 -0
- package/src/benchmark/metrics.ts +69 -0
- package/src/benchmark/report.test.ts +129 -0
- package/src/benchmark/report.ts +21 -0
- package/src/benchmark/runner.test.ts +162 -0
- package/src/benchmark/runner.ts +27 -0
- package/src/cli/commands/add.test.ts +267 -0
- package/src/cli/commands/add.ts +123 -0
- package/src/cli/commands/benchmark.test.ts +346 -0
- package/src/cli/commands/benchmark.ts +148 -0
- package/src/cli/commands/languages.test.ts +127 -0
- package/src/cli/commands/languages.ts +42 -0
- package/src/cli/commands/missing.test.ts +256 -0
- package/src/cli/commands/missing.ts +88 -0
- package/src/cli/commands/translate.test.ts +384 -0
- package/src/cli/commands/translate.ts +106 -0
- package/src/cli/commands/unused.test.ts +192 -0
- package/src/cli/commands/unused.ts +87 -0
- package/src/cli/commands/validate.test.ts +245 -0
- package/src/cli/commands/validate.ts +96 -0
- package/src/cli/config-resolution.test.ts +99 -0
- package/src/cli/config-resolution.ts +29 -0
- package/src/cli/format.test.ts +117 -0
- package/src/cli/format.ts +205 -0
- package/src/cli/formatters.test.ts +186 -0
- package/src/cli/formatters.ts +350 -0
- package/src/cli/main.ts +31 -0
- package/src/config/define-config.test.ts +66 -0
- package/src/config/define-config.ts +5 -0
- package/src/config/load-config.test.ts +35 -0
- package/src/config/load-config.ts +21 -0
- package/src/config/types.test.ts +101 -0
- package/src/config/types.ts +28 -0
- package/src/index.ts +56 -0
- package/src/keys/flatten.test.ts +111 -0
- package/src/keys/flatten.ts +41 -0
- package/src/sdk/file-io.test.ts +139 -0
- package/src/sdk/file-io.ts +21 -0
- package/src/sdk/node-layer.test.ts +54 -0
- package/src/sdk/node-layer.ts +10 -0
- package/src/sdk/php-array-reader.test.ts +114 -0
- package/src/sdk/php-array-reader.ts +26 -0
- package/src/translation/chunking.test.ts +118 -0
- package/src/translation/chunking.ts +57 -0
- package/src/translation/missing-keys.test.ts +179 -0
- package/src/translation/missing-keys.ts +36 -0
- package/src/translation/model-registry.test.ts +54 -0
- package/src/translation/model-registry.ts +43 -0
- package/src/translation/one-shot-strategy.test.ts +259 -0
- package/src/translation/one-shot-strategy.ts +48 -0
- package/src/translation/orchestrator.test.ts +276 -0
- package/src/translation/orchestrator.ts +83 -0
- package/src/translation/prompt.test.ts +149 -0
- package/src/translation/prompt.ts +42 -0
- package/src/translation/tool-loop-strategy.test.ts +279 -0
- package/src/translation/tool-loop-strategy.ts +68 -0
- package/src/translation/types.test.ts +37 -0
- package/src/translation/types.ts +21 -0
- package/tsconfig.json +9 -0
- package/tsconfig.tsbuildinfo +1 -0
- package/tsdown.config.ts +7 -0
- package/vitest.config.ts +8 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { chunkKeys } from './chunking.js';
|
|
3
|
+
|
|
4
|
+
describe('chunkKeys', () => {
|
|
5
|
+
it('keeps a small key set in a single chunk', () => {
|
|
6
|
+
const source = { a: 'A', b: 'B' };
|
|
7
|
+
const target = {};
|
|
8
|
+
const chunks = chunkKeys(['a', 'b'], source, target, { maxTokens: 3000, charsPerToken: 3.0 });
|
|
9
|
+
expect(chunks).toHaveLength(1);
|
|
10
|
+
expect(chunks[0]).toEqual(['a', 'b']);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('splits a large key set into multiple chunks', () => {
|
|
14
|
+
const source: Record<string, string> = {};
|
|
15
|
+
for (let i = 0; i < 150; i++) {
|
|
16
|
+
source[`key_${i}`] = `This is the value number ${i} with some extra text to make it longer`;
|
|
17
|
+
}
|
|
18
|
+
const target = {};
|
|
19
|
+
const keys = Object.keys(source);
|
|
20
|
+
const chunks = chunkKeys(keys, source, target, { maxTokens: 3000, charsPerToken: 3.0 });
|
|
21
|
+
expect(chunks.length).toBeGreaterThan(1);
|
|
22
|
+
const allKeys = chunks.flat();
|
|
23
|
+
expect(allKeys).toHaveLength(keys.length);
|
|
24
|
+
expect(new Set(allKeys).size).toBe(keys.length);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('preserves all keys across chunks without loss or duplication', () => {
|
|
28
|
+
const source: Record<string, string> = {};
|
|
29
|
+
for (let i = 0; i < 60; i++) {
|
|
30
|
+
source[`k${i}`] = 'word '.repeat(10);
|
|
31
|
+
}
|
|
32
|
+
const target = {};
|
|
33
|
+
const keys = Object.keys(source);
|
|
34
|
+
const chunks = chunkKeys(keys, source, target, { maxTokens: 3000, charsPerToken: 3.0 });
|
|
35
|
+
const flattened = chunks.flat();
|
|
36
|
+
expect(flattened.sort()).toEqual([...keys].sort());
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('keeps a single oversized key in its own chunk', () => {
|
|
40
|
+
const source = { long: 'x'.repeat(5000) };
|
|
41
|
+
const target = {};
|
|
42
|
+
const chunks = chunkKeys(['long'], source, target, { maxTokens: 3000, charsPerToken: 3.0 });
|
|
43
|
+
expect(chunks).toHaveLength(1);
|
|
44
|
+
expect(chunks[0]).toEqual(['long']);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('reduces effective chunk size when file context is huge', () => {
|
|
48
|
+
const source: Record<string, string> = {};
|
|
49
|
+
for (let i = 0; i < 26; i++) {
|
|
50
|
+
source[String.fromCharCode(97 + i)] = 'word '.repeat(50);
|
|
51
|
+
}
|
|
52
|
+
const target = {};
|
|
53
|
+
const keys = Object.keys(source).slice(0, 10);
|
|
54
|
+
const chunks = chunkKeys(keys, source, target, { maxTokens: 3000, charsPerToken: 3.0 });
|
|
55
|
+
expect(chunks.length).toBeGreaterThan(0);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('falls back to one key per chunk when the context exceeds the limit', () => {
|
|
59
|
+
const source: Record<string, string> = {};
|
|
60
|
+
for (let i = 0; i < 100; i++) {
|
|
61
|
+
source[`key_${i}`] = 'word '.repeat(100);
|
|
62
|
+
}
|
|
63
|
+
const target = { ...source };
|
|
64
|
+
const chunks = chunkKeys(['key_0', 'key_1'], source, target, { maxTokens: 10, charsPerToken: 3.0 });
|
|
65
|
+
expect(chunks[0]).toEqual(['key_0']);
|
|
66
|
+
expect(chunks[1]).toEqual(['key_1']);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('returns [] for empty keys', () => {
|
|
70
|
+
expect(chunkKeys([], {}, {}, { maxTokens: 3000, charsPerToken: 3.0 })).toEqual([]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('still includes keys not present in source (uses empty string as value)', () => {
|
|
74
|
+
const source = { a: 'A' };
|
|
75
|
+
const chunks = chunkKeys(['missing'], source, {}, { maxTokens: 3000, charsPerToken: 3.0 });
|
|
76
|
+
expect(chunks).toEqual([['missing']]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('handles keys with empty values', () => {
|
|
80
|
+
const source = { a: '', b: '' };
|
|
81
|
+
const chunks = chunkKeys(['a', 'b'], source, {}, { maxTokens: 3000, charsPerToken: 3.0 });
|
|
82
|
+
expect(chunks).toHaveLength(1);
|
|
83
|
+
expect(chunks[0]).toEqual(['a', 'b']);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('handles single key', () => {
|
|
87
|
+
const source = { only: 'value' };
|
|
88
|
+
const chunks = chunkKeys(['only'], source, {}, { maxTokens: 3000, charsPerToken: 3.0 });
|
|
89
|
+
expect(chunks).toHaveLength(1);
|
|
90
|
+
expect(chunks[0]).toEqual(['only']);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('handles very large number of keys', () => {
|
|
94
|
+
const source: Record<string, string> = {};
|
|
95
|
+
for (let i = 0; i < 1000; i++) {
|
|
96
|
+
source[`key_${i}`] = 'v';
|
|
97
|
+
}
|
|
98
|
+
const keys = Object.keys(source);
|
|
99
|
+
const chunks = chunkKeys(keys, source, {}, { maxTokens: 3000, charsPerToken: 3.0 });
|
|
100
|
+
const all = chunks.flat();
|
|
101
|
+
expect(all).toHaveLength(1000);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('handles keys with special characters', () => {
|
|
105
|
+
const source = { 'key.with.dots': 'value', 'key-with-dashes': 'value2' };
|
|
106
|
+
const chunks = chunkKeys(['key.with.dots', 'key-with-dashes'], source, {}, { maxTokens: 3000, charsPerToken: 3.0 });
|
|
107
|
+
expect(chunks).toHaveLength(1);
|
|
108
|
+
expect(chunks[0]).toEqual(['key.with.dots', 'key-with-dashes']);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('respects MIN_EFFECTIVE_MAX_CHARS even with zero maxTokens', () => {
|
|
112
|
+
const source = { a: 'A', b: 'B' };
|
|
113
|
+
const chunks = chunkKeys(['a', 'b'], source, {}, { maxTokens: 0, charsPerToken: 3.0 });
|
|
114
|
+
// MIN_EFFECTIVE_MAX_CHARS is 200, so both small keys fit in one chunk
|
|
115
|
+
expect(chunks).toHaveLength(1);
|
|
116
|
+
expect(chunks[0]).toEqual(['a', 'b']);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
export interface ChunkingConfig {
|
|
2
|
+
readonly maxTokens: number;
|
|
3
|
+
readonly charsPerToken: number;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
const PROMPT_OVERHEAD = 600;
|
|
7
|
+
const ITEM_JSON_OVERHEAD = 20;
|
|
8
|
+
const MIN_EFFECTIVE_MAX_CHARS = 200;
|
|
9
|
+
|
|
10
|
+
export function chunkKeys(
|
|
11
|
+
keys: readonly string[],
|
|
12
|
+
sourceMap: Readonly<Record<string, string>>,
|
|
13
|
+
targetMap: Readonly<Record<string, string>>,
|
|
14
|
+
config: ChunkingConfig,
|
|
15
|
+
): string[][] {
|
|
16
|
+
const maxChars = config.maxTokens * config.charsPerToken;
|
|
17
|
+
const sourceJson = JSON.stringify(sourceMap);
|
|
18
|
+
const targetJson = JSON.stringify(targetMap);
|
|
19
|
+
const contextOverhead = (sourceJson?.length ?? 0) + (targetJson?.length ?? 0) + PROMPT_OVERHEAD;
|
|
20
|
+
const effectiveMaxChars = Math.max(MIN_EFFECTIVE_MAX_CHARS, maxChars - contextOverhead);
|
|
21
|
+
|
|
22
|
+
const chunks: string[][] = [];
|
|
23
|
+
let currentChunk: string[] = [];
|
|
24
|
+
let currentChars = 0;
|
|
25
|
+
|
|
26
|
+
for (const key of keys) {
|
|
27
|
+
const value = sourceMap[key] ?? '';
|
|
28
|
+
const itemChars = key.length + value.length + ITEM_JSON_OVERHEAD;
|
|
29
|
+
|
|
30
|
+
// If a single key is larger than the entire chunk, force it through alone.
|
|
31
|
+
if (itemChars > effectiveMaxChars && currentChunk.length === 0) {
|
|
32
|
+
chunks.push([key]);
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (currentChunk.length > 0 && currentChars + itemChars > effectiveMaxChars) {
|
|
37
|
+
chunks.push(currentChunk);
|
|
38
|
+
currentChunk = [];
|
|
39
|
+
currentChars = 0;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
currentChunk.push(key);
|
|
43
|
+
currentChars += itemChars;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (currentChunk.length > 0) {
|
|
47
|
+
chunks.push(currentChunk);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Safety valve: if the file context is so large that no keys fit,
|
|
51
|
+
// process keys one-by-one so translation still proceeds.
|
|
52
|
+
if (chunks.length === 0 && keys.length > 0) {
|
|
53
|
+
return keys.map((key) => [key]);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return chunks;
|
|
57
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { Effect } from 'effect';
|
|
3
|
+
import { computeMissingKeys } from './missing-keys.js';
|
|
4
|
+
import type { TranslationAdapter } from '../adapter/types.js';
|
|
5
|
+
|
|
6
|
+
describe('computeMissingKeys', () => {
|
|
7
|
+
it('lists missing keys per resource/locale', async () => {
|
|
8
|
+
const adapter: TranslationAdapter = {
|
|
9
|
+
name: 'test',
|
|
10
|
+
capabilities: { canCreateResource: true, unusedKeyDetection: false },
|
|
11
|
+
listLocales: () => Effect.succeed(['en', 'de']),
|
|
12
|
+
listResources: () => Effect.succeed([{ key: 'messages', label: 'messages' }]),
|
|
13
|
+
readResource: (locale: string) =>
|
|
14
|
+
Effect.succeed(
|
|
15
|
+
locale === 'en' ? { hello: 'Hello', bye: 'Bye' } : { hello: 'Hallo' },
|
|
16
|
+
),
|
|
17
|
+
writeResource: () => Effect.void,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const result = await Effect.runPromise(
|
|
21
|
+
computeMissingKeys(adapter, 'en', ['de']),
|
|
22
|
+
) as ReadonlyArray<{ missing: readonly string[] }>;
|
|
23
|
+
expect(result).toHaveLength(1);
|
|
24
|
+
expect(result[0]!.missing).toEqual(['bye']);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('returns empty when nothing is missing', async () => {
|
|
28
|
+
const adapter: TranslationAdapter = {
|
|
29
|
+
name: 'test',
|
|
30
|
+
capabilities: { canCreateResource: true, unusedKeyDetection: false },
|
|
31
|
+
listLocales: () => Effect.succeed(['en', 'de']),
|
|
32
|
+
listResources: () => Effect.succeed([{ key: 'messages', label: 'messages' }]),
|
|
33
|
+
readResource: () => Effect.succeed({ hello: 'Hello' }),
|
|
34
|
+
writeResource: () => Effect.void,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const result = await Effect.runPromise(
|
|
38
|
+
computeMissingKeys(adapter, 'en', ['de']),
|
|
39
|
+
) as ReadonlyArray<{ missing: readonly string[] }>;
|
|
40
|
+
expect(result).toHaveLength(0);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('handles multiple resources', async () => {
|
|
44
|
+
const adapter: TranslationAdapter = {
|
|
45
|
+
name: 'test',
|
|
46
|
+
capabilities: { canCreateResource: true, unusedKeyDetection: false },
|
|
47
|
+
listLocales: () => Effect.succeed(['en', 'de']),
|
|
48
|
+
listResources: () =>
|
|
49
|
+
Effect.succeed([
|
|
50
|
+
{ key: 'auth', label: 'auth' },
|
|
51
|
+
{ key: 'validation', label: 'validation' },
|
|
52
|
+
]),
|
|
53
|
+
readResource: (locale: string, resource) =>
|
|
54
|
+
Effect.succeed(
|
|
55
|
+
locale === 'en'
|
|
56
|
+
? resource.key === 'auth'
|
|
57
|
+
? { login: 'Login' }
|
|
58
|
+
: { email: 'Email' }
|
|
59
|
+
: resource.key === 'auth'
|
|
60
|
+
? { login: 'Anmelden' }
|
|
61
|
+
: {},
|
|
62
|
+
),
|
|
63
|
+
writeResource: () => Effect.void,
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const result = await Effect.runPromise(
|
|
67
|
+
computeMissingKeys(adapter, 'en', ['de']),
|
|
68
|
+
) as ReadonlyArray<{ resource: { label: string }; missing: readonly string[] }>;
|
|
69
|
+
expect(result).toHaveLength(1);
|
|
70
|
+
expect(result[0]!.resource.label).toBe('validation');
|
|
71
|
+
expect(result[0]!.missing).toEqual(['email']);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('handles multiple target locales', async () => {
|
|
75
|
+
const adapter: TranslationAdapter = {
|
|
76
|
+
name: 'test',
|
|
77
|
+
capabilities: { canCreateResource: true, unusedKeyDetection: false },
|
|
78
|
+
listLocales: () => Effect.succeed(['en', 'de', 'fr']),
|
|
79
|
+
listResources: () => Effect.succeed([{ key: 'messages', label: 'messages' }]),
|
|
80
|
+
readResource: (locale: string) =>
|
|
81
|
+
Effect.succeed(
|
|
82
|
+
locale === 'en' ? { hello: 'Hello' } : {},
|
|
83
|
+
),
|
|
84
|
+
writeResource: () => Effect.void,
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const result = await Effect.runPromise(
|
|
88
|
+
computeMissingKeys(adapter, 'en', ['de', 'fr']),
|
|
89
|
+
) as ReadonlyArray<{ locale: string; missing: readonly string[] }>;
|
|
90
|
+
expect(result).toHaveLength(2);
|
|
91
|
+
const locales = result.map((r) => r.locale);
|
|
92
|
+
expect(locales).toContain('de');
|
|
93
|
+
expect(locales).toContain('fr');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('handles empty target locales', async () => {
|
|
97
|
+
const adapter: TranslationAdapter = {
|
|
98
|
+
name: 'test',
|
|
99
|
+
capabilities: { canCreateResource: true, unusedKeyDetection: false },
|
|
100
|
+
listLocales: () => Effect.succeed(['en']),
|
|
101
|
+
listResources: () => Effect.succeed([{ key: 'messages', label: 'messages' }]),
|
|
102
|
+
readResource: () => Effect.succeed({}),
|
|
103
|
+
writeResource: () => Effect.void,
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const result = await Effect.runPromise(
|
|
107
|
+
computeMissingKeys(adapter, 'en', []),
|
|
108
|
+
) as ReadonlyArray<{ missing: readonly string[] }>;
|
|
109
|
+
expect(result).toHaveLength(0);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('handles empty resources', async () => {
|
|
113
|
+
const adapter: TranslationAdapter = {
|
|
114
|
+
name: 'test',
|
|
115
|
+
capabilities: { canCreateResource: true, unusedKeyDetection: false },
|
|
116
|
+
listLocales: () => Effect.succeed(['en', 'de']),
|
|
117
|
+
listResources: () => Effect.succeed([]),
|
|
118
|
+
readResource: () => Effect.succeed({}),
|
|
119
|
+
writeResource: () => Effect.void,
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const result = await Effect.runPromise(
|
|
123
|
+
computeMissingKeys(adapter, 'en', ['de']),
|
|
124
|
+
) as ReadonlyArray<{ missing: readonly string[] }>;
|
|
125
|
+
expect(result).toHaveLength(0);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('handles adapter readResource failure', async () => {
|
|
129
|
+
const adapter: TranslationAdapter = {
|
|
130
|
+
name: 'broken',
|
|
131
|
+
capabilities: { canCreateResource: true, unusedKeyDetection: false },
|
|
132
|
+
listLocales: () => Effect.succeed(['en', 'de']),
|
|
133
|
+
listResources: () => Effect.succeed([{ key: 'messages', label: 'messages' }]),
|
|
134
|
+
readResource: () => Effect.fail(new Error('read failed') as never),
|
|
135
|
+
writeResource: () => Effect.void,
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
await expect(Effect.runPromise(computeMissingKeys(adapter, 'en', ['de']))).rejects.toThrow('read failed');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('handles all keys missing', async () => {
|
|
142
|
+
const adapter: TranslationAdapter = {
|
|
143
|
+
name: 'test',
|
|
144
|
+
capabilities: { canCreateResource: true, unusedKeyDetection: false },
|
|
145
|
+
listLocales: () => Effect.succeed(['en', 'de']),
|
|
146
|
+
listResources: () => Effect.succeed([{ key: 'messages', label: 'messages' }]),
|
|
147
|
+
readResource: (locale: string) =>
|
|
148
|
+
Effect.succeed(
|
|
149
|
+
locale === 'en' ? { a: 'A', b: 'B', c: 'C' } : {},
|
|
150
|
+
),
|
|
151
|
+
writeResource: () => Effect.void,
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const result = await Effect.runPromise(
|
|
155
|
+
computeMissingKeys(adapter, 'en', ['de']),
|
|
156
|
+
) as ReadonlyArray<{ missing: readonly string[] }>;
|
|
157
|
+
expect(result).toHaveLength(1);
|
|
158
|
+
expect(result[0]!.missing).toEqual(['a', 'b', 'c']);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('ignores target keys not present in source', async () => {
|
|
162
|
+
const adapter: TranslationAdapter = {
|
|
163
|
+
name: 'test',
|
|
164
|
+
capabilities: { canCreateResource: true, unusedKeyDetection: false },
|
|
165
|
+
listLocales: () => Effect.succeed(['en', 'de']),
|
|
166
|
+
listResources: () => Effect.succeed([{ key: 'messages', label: 'messages' }]),
|
|
167
|
+
readResource: (locale: string) =>
|
|
168
|
+
Effect.succeed(
|
|
169
|
+
locale === 'en' ? { a: 'A' } : { a: 'A-de', b: 'B-de' },
|
|
170
|
+
),
|
|
171
|
+
writeResource: () => Effect.void,
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const result = await Effect.runPromise(
|
|
175
|
+
computeMissingKeys(adapter, 'en', ['de']),
|
|
176
|
+
) as ReadonlyArray<{ missing: readonly string[] }>;
|
|
177
|
+
expect(result).toHaveLength(0);
|
|
178
|
+
});
|
|
179
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { Effect } from 'effect';
|
|
2
|
+
import type { TranslationAdapter, ResourceRef, AdapterReadError } from '../adapter/types.js';
|
|
3
|
+
import { diffKeys } from '../keys/flatten.js';
|
|
4
|
+
|
|
5
|
+
export interface MissingKeyEntry {
|
|
6
|
+
readonly adapter: string;
|
|
7
|
+
readonly locale: string;
|
|
8
|
+
readonly resource: ResourceRef;
|
|
9
|
+
readonly missing: readonly string[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function computeMissingKeys(
|
|
13
|
+
adapter: TranslationAdapter,
|
|
14
|
+
sourceLocale: string,
|
|
15
|
+
targetLocales: readonly string[],
|
|
16
|
+
): Effect.Effect<readonly MissingKeyEntry[], AdapterReadError> {
|
|
17
|
+
return Effect.gen(function* () {
|
|
18
|
+
const resources = yield* adapter.listResources(sourceLocale);
|
|
19
|
+
const entries = yield* Effect.forEach(resources, (resource: ResourceRef) =>
|
|
20
|
+
Effect.gen(function* () {
|
|
21
|
+
const sourceMap = yield* adapter.readResource(sourceLocale, resource);
|
|
22
|
+
const localeEntries = yield* Effect.forEach(targetLocales, (locale: string) =>
|
|
23
|
+
Effect.gen(function* () {
|
|
24
|
+
const targetMap = yield* adapter.readResource(locale, resource);
|
|
25
|
+
const missing = diffKeys(sourceMap, targetMap);
|
|
26
|
+
return missing.length > 0
|
|
27
|
+
? [{ adapter: adapter.name, locale, resource, missing }]
|
|
28
|
+
: [];
|
|
29
|
+
}),
|
|
30
|
+
);
|
|
31
|
+
return localeEntries.flat();
|
|
32
|
+
}),
|
|
33
|
+
);
|
|
34
|
+
return entries.flat();
|
|
35
|
+
});
|
|
36
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { Effect, Either } from 'effect';
|
|
3
|
+
import { resolveModel, UnknownProviderError } from './model-registry.js';
|
|
4
|
+
|
|
5
|
+
describe('resolveModel', () => {
|
|
6
|
+
it('returns UnknownProviderError for unknown provider', async () => {
|
|
7
|
+
const program = resolveModel({ provider: 'unknown', modelId: 'x' });
|
|
8
|
+
const exit = await Effect.runPromise(Effect.either(program)) as Either.Either<unknown, UnknownProviderError>;
|
|
9
|
+
if (exit._tag === 'Left') {
|
|
10
|
+
expect(exit.left._tag).toBe('UnknownProviderError');
|
|
11
|
+
expect(exit.left.provider).toBe('unknown');
|
|
12
|
+
} else {
|
|
13
|
+
throw new Error('Expected Left');
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('returns UnknownProviderError for empty provider string', async () => {
|
|
18
|
+
const program = resolveModel({ provider: '', modelId: 'x' });
|
|
19
|
+
const exit = await Effect.runPromise(Effect.either(program)) as Either.Either<unknown, UnknownProviderError>;
|
|
20
|
+
if (exit._tag === 'Left') {
|
|
21
|
+
expect(exit.left._tag).toBe('UnknownProviderError');
|
|
22
|
+
expect(exit.left.provider).toBe('');
|
|
23
|
+
} else {
|
|
24
|
+
throw new Error('Expected Left');
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('resolves openai provider when package is available', async () => {
|
|
29
|
+
const program = resolveModel({ provider: 'openai', modelId: 'gpt-4o' });
|
|
30
|
+
const exit = await Effect.runPromise(Effect.either(program)) as Either.Either<unknown, UnknownProviderError>;
|
|
31
|
+
// Package is installed in this repo, so it should succeed
|
|
32
|
+
expect(exit._tag).toBe('Right');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('UnknownProviderError preserves provider in message', () => {
|
|
36
|
+
const err = new UnknownProviderError({ provider: 'foo' });
|
|
37
|
+
expect(err.provider).toBe('foo');
|
|
38
|
+
expect(err._tag).toBe('UnknownProviderError');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('accepts all known providers without type error', () => {
|
|
42
|
+
const providers = ['openai', 'anthropic', 'google'] as const;
|
|
43
|
+
for (const provider of providers) {
|
|
44
|
+
// Should compile without error
|
|
45
|
+
const config = { provider, modelId: 'test' };
|
|
46
|
+
expect(config.provider).toBe(provider);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('modelId is passed through', () => {
|
|
51
|
+
const config = { provider: 'openai' as const, modelId: 'gpt-4o' };
|
|
52
|
+
expect(config.modelId).toBe('gpt-4o');
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { Effect, Data } from 'effect';
|
|
2
|
+
import type { LanguageModel } from 'ai';
|
|
3
|
+
|
|
4
|
+
export class UnknownProviderError extends Data.TaggedError('UnknownProviderError')<{
|
|
5
|
+
readonly provider: string;
|
|
6
|
+
}> {}
|
|
7
|
+
|
|
8
|
+
export interface ModelConfig {
|
|
9
|
+
readonly provider: string;
|
|
10
|
+
readonly modelId: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The one file in the entire codebase allowed to import AI SDK provider packages.
|
|
15
|
+
*/
|
|
16
|
+
export function resolveModel(
|
|
17
|
+
config: ModelConfig,
|
|
18
|
+
): Effect.Effect<LanguageModel, UnknownProviderError> {
|
|
19
|
+
return Effect.tryPromise({
|
|
20
|
+
try: async () => {
|
|
21
|
+
switch (config.provider) {
|
|
22
|
+
case 'openai': {
|
|
23
|
+
const { openai } = await import('@ai-sdk/openai');
|
|
24
|
+
return openai(config.modelId);
|
|
25
|
+
}
|
|
26
|
+
case 'anthropic': {
|
|
27
|
+
const { anthropic } = await import('@ai-sdk/anthropic');
|
|
28
|
+
return anthropic(config.modelId);
|
|
29
|
+
}
|
|
30
|
+
case 'google': {
|
|
31
|
+
const { google } = await import('@ai-sdk/google');
|
|
32
|
+
return google(config.modelId);
|
|
33
|
+
}
|
|
34
|
+
default:
|
|
35
|
+
throw new UnknownProviderError({ provider: config.provider });
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
catch: (cause) =>
|
|
39
|
+
cause instanceof UnknownProviderError
|
|
40
|
+
? cause
|
|
41
|
+
: new UnknownProviderError({ provider: config.provider }),
|
|
42
|
+
});
|
|
43
|
+
}
|