opencode-morphllm 0.0.5 → 0.0.7

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.
Files changed (39) hide show
  1. package/.gitleaks.toml +6 -0
  2. package/.husky/pre-commit +3 -1
  3. package/.prettierignore +1 -0
  4. package/.prettierrc +1 -1
  5. package/README.md +21 -2
  6. package/bun.lock +23 -0
  7. package/bunfig.toml +4 -0
  8. package/dist/index.js +13 -14
  9. package/dist/morph/mcps.js +15 -0
  10. package/dist/morph/mcps.test.d.ts +1 -0
  11. package/dist/morph/mcps.test.js +39 -0
  12. package/dist/morph/router.d.ts +22 -0
  13. package/dist/morph/router.js +65 -0
  14. package/dist/morph/router.test.d.ts +1 -0
  15. package/dist/morph/router.test.js +239 -0
  16. package/dist/shared/config.d.ts +29 -0
  17. package/dist/shared/config.js +80 -0
  18. package/dist/shared/config.test.d.ts +1 -0
  19. package/dist/shared/config.test.js +201 -0
  20. package/dist/shared/opencode-config-dir.d.ts +18 -8
  21. package/dist/shared/opencode-config-dir.js +93 -47
  22. package/dist/shared/opencode-config-dir.test.d.ts +1 -0
  23. package/dist/shared/opencode-config-dir.test.js +310 -0
  24. package/package.json +3 -2
  25. package/src/index.ts +3 -4
  26. package/src/morph/mcps.test.ts +51 -0
  27. package/src/{mcps.ts → morph/mcps.ts} +1 -1
  28. package/src/morph/router.test.ts +267 -0
  29. package/src/{router.ts → morph/router.ts} +29 -3
  30. package/src/shared/config.test.ts +257 -0
  31. package/src/{config.ts → shared/config.ts} +30 -6
  32. package/src/shared/opencode-config-dir.test.ts +404 -0
  33. package/src/shared/opencode-config-dir.ts +90 -11
  34. package/dist/config.d.ts +0 -20
  35. package/dist/config.js +0 -71
  36. package/dist/mcps.js +0 -15
  37. package/dist/router.d.ts +0 -27
  38. package/dist/router.js +0 -51
  39. /package/dist/{mcps.d.ts → morph/mcps.d.ts} +0 -0
@@ -6,7 +6,9 @@ import {
6
6
  MORPH_MODEL_MEDIUM,
7
7
  MORPH_MODEL_HARD,
8
8
  MORPH_MODEL_DEFAULT,
9
- } from './config';
9
+ MORPH_ROUTER_PROMPT_CACHING_AWARE,
10
+ MORPH_ROUTER_ENABLED,
11
+ } from '../shared/config';
10
12
  import type { Part, UserMessage } from '@opencode-ai/sdk';
11
13
  import type {
12
14
  RouterInput,
@@ -14,7 +16,17 @@ import type {
14
16
  ComplexityLevel,
15
17
  } from '@morphllm/morphsdk';
16
18
 
17
- const morph = new MorphClient({ apiKey: API_KEY });
19
+ // Lazy initialization to allow mocking in tests
20
+ let morph: MorphClient | null = null;
21
+
22
+ const sessionsWithModelSelected = new Set<string>();
23
+
24
+ function getMorphClient(): MorphClient {
25
+ if (!morph) {
26
+ morph = new MorphClient({ apiKey: API_KEY });
27
+ }
28
+ return morph;
29
+ }
18
30
 
19
31
  function parseModel(s?: string): { providerID: string; modelID: string } {
20
32
  if (!s) return { providerID: '', modelID: '' };
@@ -54,11 +66,21 @@ export function createModelRouterHook() {
54
66
  ): Promise<void> => {
55
67
  input.model = input.model ?? { providerID: '', modelID: '' };
56
68
 
69
+ if (!MORPH_ROUTER_ENABLED) {
70
+ return;
71
+ }
72
+
73
+ if (MORPH_ROUTER_PROMPT_CACHING_AWARE) {
74
+ if (sessionsWithModelSelected.has(input.sessionID)) {
75
+ return;
76
+ }
77
+ }
78
+
57
79
  const promptText = extractPromptText(output.parts);
58
80
 
59
81
  const classifier =
60
82
  input.classify ??
61
- ((args: RouterInput) => morph.routers.raw.classify(args));
83
+ ((args: RouterInput) => getMorphClient().routers.raw.classify(args));
62
84
 
63
85
  const classification: RawRouterResult = await classifier({
64
86
  input: promptText,
@@ -71,6 +93,10 @@ export function createModelRouterHook() {
71
93
 
72
94
  input.model.providerID = finalProviderID;
73
95
  input.model.modelID = finalModelID;
96
+
97
+ if (MORPH_ROUTER_ENABLED && MORPH_ROUTER_PROMPT_CACHING_AWARE) {
98
+ sessionsWithModelSelected.add(input.sessionID);
99
+ }
74
100
  },
75
101
  };
76
102
  }
@@ -0,0 +1,257 @@
1
+ import {
2
+ describe,
3
+ it,
4
+ expect,
5
+ beforeEach,
6
+ afterEach,
7
+ beforeAll,
8
+ afterAll,
9
+ } from 'bun:test';
10
+ import {
11
+ existsSync,
12
+ writeFileSync,
13
+ readFileSync,
14
+ rmSync,
15
+ mkdirSync,
16
+ } from 'node:fs';
17
+ import { join } from 'node:path';
18
+ import { tmpdir } from 'node:os';
19
+ import { env } from 'node:process';
20
+
21
+ // Use a real temporary directory for testing - no mocking needed
22
+ const mockConfigDir = join(tmpdir(), 'mock-morph-config-test');
23
+ const mockMorphJson = join(mockConfigDir, 'morph.json');
24
+ const mockMorphJsonc = join(mockConfigDir, 'morph.jsonc');
25
+
26
+ // Save original environment
27
+ let originalEnv: Record<string, string | undefined>;
28
+
29
+ import {
30
+ getMorphPluginConfigPath,
31
+ loadMorphPluginConfig,
32
+ loadMorphPluginConfigWithProjectOverride,
33
+ } from './config';
34
+
35
+ describe('config.ts', () => {
36
+ beforeAll(() => {
37
+ // Save original environment and set custom config dir
38
+ originalEnv = {
39
+ OPENCODE_CONFIG_DIR: env.OPENCODE_CONFIG_DIR,
40
+ };
41
+ env.OPENCODE_CONFIG_DIR = mockConfigDir;
42
+
43
+ // Create mock config directory
44
+ if (!existsSync(mockConfigDir)) {
45
+ mkdirSync(mockConfigDir, { recursive: true });
46
+ }
47
+ });
48
+
49
+ afterAll(() => {
50
+ // Restore original environment
51
+ if (originalEnv.OPENCODE_CONFIG_DIR !== undefined) {
52
+ env.OPENCODE_CONFIG_DIR = originalEnv.OPENCODE_CONFIG_DIR;
53
+ } else {
54
+ delete env.OPENCODE_CONFIG_DIR;
55
+ }
56
+
57
+ // Cleanup
58
+ try {
59
+ if (existsSync(mockMorphJson)) rmSync(mockMorphJson);
60
+ if (existsSync(mockMorphJsonc)) rmSync(mockMorphJsonc);
61
+ if (existsSync(mockConfigDir)) rmSync(mockConfigDir, { recursive: true });
62
+ } catch {
63
+ // Ignore cleanup errors
64
+ }
65
+ });
66
+
67
+ beforeEach(() => {
68
+ // Clean up any existing config files before each test
69
+ try {
70
+ if (existsSync(mockMorphJson)) rmSync(mockMorphJson);
71
+ if (existsSync(mockMorphJsonc)) rmSync(mockMorphJsonc);
72
+ } catch {
73
+ // Ignore cleanup errors
74
+ }
75
+ });
76
+
77
+ describe('getMorphPluginConfigPath', () => {
78
+ it('should return path to morph.json in config directory', () => {
79
+ const path = getMorphPluginConfigPath();
80
+ expect(path).toContain('morph.json');
81
+ expect(path).toContain(mockConfigDir);
82
+ });
83
+ });
84
+
85
+ describe('loadMorphPluginConfig', () => {
86
+ it('should return null when no config files exist', () => {
87
+ const result = loadMorphPluginConfig();
88
+ expect(result).toBeNull();
89
+ });
90
+
91
+ it('should load config from .jsonc file', () => {
92
+ const content = `{
93
+ "MORPH_API_KEY": "test-key-123",
94
+ "MORPH_ROUTER_ENABLED": false
95
+ }`;
96
+ writeFileSync(mockMorphJsonc, content);
97
+
98
+ const result = loadMorphPluginConfig();
99
+ expect(result).not.toBeNull();
100
+ expect(result?.MORPH_API_KEY).toBe('test-key-123');
101
+ expect(result?.MORPH_ROUTER_ENABLED).toBe(false);
102
+ });
103
+
104
+ it('should load config from .json file', () => {
105
+ const content = JSON.stringify({
106
+ MORPH_API_KEY: 'json-key-456',
107
+ MORPH_MODEL_EASY: 'provider/model-easy',
108
+ });
109
+ writeFileSync(mockMorphJson, content);
110
+
111
+ const result = loadMorphPluginConfig();
112
+ expect(result).not.toBeNull();
113
+ expect(result?.MORPH_API_KEY).toBe('json-key-456');
114
+ expect(result?.MORPH_MODEL_EASY).toBe('provider/model-easy');
115
+ });
116
+
117
+ it('should prefer .jsonc over .json when both exist', () => {
118
+ const jsoncContent = '{"MORPH_API_KEY": "from-jsonc"}';
119
+ const jsonContent = '{"MORPH_API_KEY": "from-json"}';
120
+
121
+ writeFileSync(mockMorphJsonc, jsoncContent);
122
+ writeFileSync(mockMorphJson, jsonContent);
123
+
124
+ const result = loadMorphPluginConfig();
125
+ expect(result?.MORPH_API_KEY).toBe('from-jsonc');
126
+ });
127
+
128
+ it('should return null for invalid .jsonc content', () => {
129
+ writeFileSync(mockMorphJsonc, 'invalid json content');
130
+
131
+ const result = loadMorphPluginConfig();
132
+ expect(result).toBeNull();
133
+ });
134
+
135
+ it('should return null for invalid .json content', () => {
136
+ writeFileSync(mockMorphJson, 'invalid json content');
137
+
138
+ const result = loadMorphPluginConfig();
139
+ expect(result).toBeNull();
140
+ });
141
+
142
+ it('should handle all MORPH config fields', () => {
143
+ const content = JSON.stringify({
144
+ MORPH_API_KEY: 'api-key',
145
+ MORPH_ROUTER_ENABLED: true,
146
+ MORPH_MODEL_EASY: 'easy/provider/model',
147
+ MORPH_MODEL_MEDIUM: 'medium/provider/model',
148
+ MORPH_MODEL_HARD: 'hard/provider/model',
149
+ MORPH_MODEL_DEFAULT: 'default/provider/model',
150
+ });
151
+ writeFileSync(mockMorphJson, content);
152
+
153
+ const result = loadMorphPluginConfig();
154
+ expect(result).toEqual({
155
+ MORPH_API_KEY: 'api-key',
156
+ MORPH_ROUTER_ENABLED: true,
157
+ MORPH_MODEL_EASY: 'easy/provider/model',
158
+ MORPH_MODEL_MEDIUM: 'medium/provider/model',
159
+ MORPH_MODEL_HARD: 'hard/provider/model',
160
+ MORPH_MODEL_DEFAULT: 'default/provider/model',
161
+ });
162
+ });
163
+
164
+ it('should handle nested MORPH_ROUTER_CONFIGS', () => {
165
+ const content = JSON.stringify({
166
+ MORPH_API_KEY: 'api-key',
167
+ MORPH_ROUTER_CONFIGS: {
168
+ MORPH_ROUTER_ENABLED: true,
169
+ MORPH_MODEL_EASY: 'easy/provider/model',
170
+ MORPH_MODEL_MEDIUM: 'medium/provider/model',
171
+ MORPH_MODEL_HARD: 'hard/provider/model',
172
+ MORPH_MODEL_DEFAULT: 'default/provider/model',
173
+ },
174
+ });
175
+ writeFileSync(mockMorphJson, content);
176
+
177
+ const result = loadMorphPluginConfig();
178
+ expect(result?.MORPH_API_KEY).toBe('api-key');
179
+ expect(result?.MORPH_ROUTER_CONFIGS).toEqual({
180
+ MORPH_ROUTER_ENABLED: true,
181
+ MORPH_MODEL_EASY: 'easy/provider/model',
182
+ MORPH_MODEL_MEDIUM: 'medium/provider/model',
183
+ MORPH_MODEL_HARD: 'hard/provider/model',
184
+ MORPH_MODEL_DEFAULT: 'default/provider/model',
185
+ });
186
+ });
187
+ });
188
+
189
+ describe('loadMorphPluginConfigWithProjectOverride', () => {
190
+ it('should return empty object when no config exists', () => {
191
+ const result =
192
+ loadMorphPluginConfigWithProjectOverride('/non-existent-path');
193
+ expect(result).toEqual({});
194
+ });
195
+
196
+ it('should merge user config with project config', () => {
197
+ // Create user config
198
+ writeFileSync(
199
+ mockMorphJson,
200
+ JSON.stringify({
201
+ MORPH_API_KEY: 'user-api-key',
202
+ MORPH_MODEL_EASY: 'user-easy-model',
203
+ })
204
+ );
205
+
206
+ // Create project config directory
207
+ const projectDir = join(tmpdir(), 'test-project');
208
+ const projectConfigDir = join(projectDir, '.opencode');
209
+ const projectConfigPath = join(projectConfigDir, 'morph.json');
210
+
211
+ mkdirSync(projectConfigDir, { recursive: true });
212
+ writeFileSync(
213
+ projectConfigPath,
214
+ JSON.stringify({
215
+ MORPH_API_KEY: 'project-api-key',
216
+ MORPH_MODEL_HARD: 'project-hard-model',
217
+ })
218
+ );
219
+
220
+ const result = loadMorphPluginConfigWithProjectOverride(projectDir);
221
+
222
+ // Project config should override user config
223
+ expect(result.MORPH_API_KEY).toBe('project-api-key');
224
+ expect(result.MORPH_MODEL_EASY).toBe('user-easy-model');
225
+ expect(result.MORPH_MODEL_HARD).toBe('project-hard-model');
226
+ });
227
+
228
+ it('should handle project .jsonc config', () => {
229
+ // Create user config
230
+ writeFileSync(
231
+ mockMorphJson,
232
+ JSON.stringify({
233
+ MORPH_API_KEY: 'user-key',
234
+ })
235
+ );
236
+
237
+ // Create project config with .jsonc
238
+ const projectDir = join(tmpdir(), 'test-project2');
239
+ const projectConfigDir = join(projectDir, '.opencode');
240
+ const projectConfigPath = join(projectConfigDir, 'morph.jsonc');
241
+
242
+ mkdirSync(projectConfigDir, { recursive: true });
243
+ const jsoncContent = `
244
+ // Project config with comments
245
+ {
246
+ "MORPH_MODEL_MEDIUM": "project-medium-model"
247
+ }
248
+ `;
249
+ writeFileSync(projectConfigPath, jsoncContent);
250
+
251
+ const result = loadMorphPluginConfigWithProjectOverride(projectDir);
252
+
253
+ expect(result.MORPH_API_KEY).toBe('user-key');
254
+ expect(result.MORPH_MODEL_MEDIUM).toBe('project-medium-model');
255
+ });
256
+ });
257
+ });
@@ -1,6 +1,6 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
- import { getOpenCodeConfigDir } from './shared/opencode-config-dir';
3
+ import { getOpenCodeConfigDir } from './opencode-config-dir';
4
4
 
5
5
  const MORPH_PLUGIN_NAME = 'morph';
6
6
 
@@ -9,9 +9,21 @@ export function getMorphPluginConfigPath(): string {
9
9
  return join(configDir, `${MORPH_PLUGIN_NAME}.json`);
10
10
  }
11
11
 
12
+ interface MorphRouterConfigs {
13
+ MORPH_ROUTER_ENABLED?: boolean;
14
+ MORPH_ROUTER_PROMPT_CACHING_AWARE?: boolean;
15
+ MORPH_MODEL_EASY?: string;
16
+ MORPH_MODEL_MEDIUM?: string;
17
+ MORPH_MODEL_HARD?: string;
18
+ MORPH_MODEL_DEFAULT?: string;
19
+ }
20
+
12
21
  interface MorphConfig {
13
22
  MORPH_API_KEY?: string;
23
+ MORPH_ROUTER_CONFIGS?: MorphRouterConfigs;
24
+ // Legacy fields for backward compatibility
14
25
  MORPH_ROUTER_ENABLED?: boolean;
26
+ MORPH_ROUTER_PROMPT_CACHING_AWARE?: boolean;
15
27
  MORPH_MODEL_EASY?: string;
16
28
  MORPH_MODEL_MEDIUM?: string;
17
29
  MORPH_MODEL_HARD?: string;
@@ -85,10 +97,22 @@ export function loadMorphPluginConfigWithProjectOverride(
85
97
 
86
98
  const config = loadMorphPluginConfigWithProjectOverride();
87
99
 
100
+ const routerConfigs = config.MORPH_ROUTER_CONFIGS || {};
101
+
88
102
  export const API_KEY = config.MORPH_API_KEY || '';
89
- export const MORPH_MODEL_EASY = config.MORPH_MODEL_EASY || '';
90
- export const MORPH_MODEL_MEDIUM = config.MORPH_MODEL_MEDIUM || '';
91
- export const MORPH_MODEL_HARD = config.MORPH_MODEL_HARD || '';
103
+ export const MORPH_MODEL_EASY =
104
+ routerConfigs.MORPH_MODEL_EASY || config.MORPH_MODEL_EASY || '';
105
+ export const MORPH_MODEL_MEDIUM =
106
+ routerConfigs.MORPH_MODEL_MEDIUM || config.MORPH_MODEL_MEDIUM || '';
107
+ export const MORPH_MODEL_HARD =
108
+ routerConfigs.MORPH_MODEL_HARD || config.MORPH_MODEL_HARD || '';
92
109
  export const MORPH_MODEL_DEFAULT =
93
- config.MORPH_MODEL_DEFAULT || MORPH_MODEL_MEDIUM;
94
- export const MORPH_ROUTER_ENABLED = config.MORPH_ROUTER_ENABLED ?? true;
110
+ routerConfigs.MORPH_MODEL_DEFAULT ||
111
+ config.MORPH_MODEL_DEFAULT ||
112
+ MORPH_MODEL_MEDIUM;
113
+ export const MORPH_ROUTER_ENABLED =
114
+ routerConfigs.MORPH_ROUTER_ENABLED ?? config.MORPH_ROUTER_ENABLED ?? true;
115
+ export const MORPH_ROUTER_PROMPT_CACHING_AWARE =
116
+ routerConfigs.MORPH_ROUTER_PROMPT_CACHING_AWARE ??
117
+ config.MORPH_ROUTER_PROMPT_CACHING_AWARE ??
118
+ false;