@gengjiawen/os-init 1.18.1 → 1.20.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.
@@ -21,11 +21,14 @@ jobs:
21
21
  version: 9
22
22
  - name: Envinfo
23
23
  run: npx envinfo
24
- - name: Install, build, and test (pnpm)
24
+ - name: Install and build (pnpm)
25
25
  run: |
26
26
  pnpm install --frozen-lockfile=false
27
27
  pnpm build
28
- pnpm format:check
29
- pnpm test
28
+ - name: Check formatting (pnpm)
29
+ if: runner.os != 'Windows'
30
+ run: pnpm format:check
31
+ - name: Test (pnpm)
32
+ run: pnpm test
30
33
  env:
31
34
  CI: true
package/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.20.0](https://github.com/gengjiawen/os-init/compare/v1.19.0...v1.20.0) (2026-03-27)
4
+
5
+
6
+ ### Features
7
+
8
+ * add more opencode models ([e04e0d1](https://github.com/gengjiawen/os-init/commit/e04e0d1fc52b7a93cf51436d23c82b1e725daff6))
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * add Claude nonessential traffic env ([9bf1bac](https://github.com/gengjiawen/os-init/commit/9bf1bac9f09e8c9a3e8c740834a1d5ad7986f1b9))
14
+ * CI ([166259f](https://github.com/gengjiawen/os-init/commit/166259f104bdda2dc57cd4678d9e03d739c472a9))
15
+ * use absolute codex model catalog path on windows ([fb8f19f](https://github.com/gengjiawen/os-init/commit/fb8f19f8f6b3de437b2192e57ec7e7fe1e48ee5a))
16
+
17
+ ## [1.19.0](https://github.com/gengjiawen/os-init/compare/v1.18.1...v1.19.0) (2026-03-19)
18
+
19
+
20
+ ### Features
21
+
22
+ * refresh codex model catalog during setup ([9ed724a](https://github.com/gengjiawen/os-init/commit/9ed724accda9901a813bb9265895db6879d406bb))
23
+
24
+
25
+ ### Bug Fixes
26
+
27
+ * add model metadata to opencode config ([59ade2c](https://github.com/gengjiawen/os-init/commit/59ade2c9e9daa307de56d30968a1c3e8977ea945))
28
+
3
29
  ## [1.18.1](https://github.com/gengjiawen/os-init/compare/v1.18.0...v1.18.1) (2026-03-16)
4
30
 
5
31
 
package/bin/bin.js CHANGED
@@ -65,9 +65,11 @@ program
65
65
  return
66
66
  }
67
67
  try {
68
- const { configPath, authPath } = writeCodexConfig(apiKey)
68
+ const { configPath, authPath, catalogPath } =
69
+ await writeCodexConfig(apiKey)
69
70
  console.log(`Codex config written to: ${configPath}`)
70
71
  console.log(`Codex auth written to: ${authPath}`)
72
+ console.log(`Codex model catalog written to: ${catalogPath}`)
71
73
  await installCodexDeps()
72
74
  } catch (err) {
73
75
  console.error('Failed to setup Codex:', err.message)
@@ -146,7 +148,7 @@ program
146
148
  : 'Setting up Claude Code + Codex + OpenCode...\n'
147
149
  )
148
150
 
149
- const result = writeAllAgentsConfig(apiKey, { full })
151
+ const result = await writeAllAgentsConfig(apiKey, { full })
150
152
 
151
153
  console.log('Claude Code:')
152
154
  console.log(` Settings written to: ${result.claude.settingsPath}`)
@@ -157,6 +159,7 @@ program
157
159
  console.log('\nCodex:')
158
160
  console.log(` Config written to: ${result.codex.configPath}`)
159
161
  console.log(` Auth written to: ${result.codex.authPath}`)
162
+ console.log(` Model catalog written to: ${result.codex.catalogPath}`)
160
163
 
161
164
  console.log('\nOpenCode:')
162
165
  console.log(` Config written to: ${result.opencode.configPath}`)
@@ -9,6 +9,7 @@ export interface AllAgentsResult {
9
9
  codex: {
10
10
  configPath: string;
11
11
  authPath: string;
12
+ catalogPath: string;
12
13
  };
13
14
  opencode: {
14
15
  configPath: string;
@@ -18,5 +19,5 @@ export interface AllAgentsResult {
18
19
  settingsPath: string;
19
20
  };
20
21
  }
21
- export declare function writeAllAgentsConfig(apiKey: string, options?: AllAgentsOptions): AllAgentsResult;
22
+ export declare function writeAllAgentsConfig(apiKey: string, options?: AllAgentsOptions): Promise<AllAgentsResult>;
22
23
  export declare function installAllAgentsDeps(options?: AllAgentsOptions): Promise<void>;
@@ -6,9 +6,9 @@ const claude_code_1 = require("./claude-code");
6
6
  const codex_1 = require("./codex");
7
7
  const gemini_cli_1 = require("./gemini-cli");
8
8
  const opencode_1 = require("./opencode");
9
- function writeAllAgentsConfig(apiKey, options = {}) {
9
+ async function writeAllAgentsConfig(apiKey, options = {}) {
10
10
  const claudeResult = (0, claude_code_1.writeClaudeConfig)(apiKey);
11
- const codexResult = (0, codex_1.writeCodexConfig)(apiKey);
11
+ const codexResult = await (0, codex_1.writeCodexConfig)(apiKey);
12
12
  const opencodeResult = (0, opencode_1.writeOpencodeConfig)(apiKey);
13
13
  const result = {
14
14
  claude: claudeResult,
@@ -62,6 +62,7 @@ function writeVSCodeClaudePluginConfig(apiKey) {
62
62
  const edits = (0, jsonc_parser_1.modify)(sourceContent, ['claudeCode.environmentVariables'], [
63
63
  { name: 'ANTHROPIC_BASE_URL', value: CLAUDE_BASE_URL },
64
64
  { name: 'ANTHROPIC_AUTH_TOKEN', value: apiKey },
65
+ { name: 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC', value: '1' },
65
66
  ], {
66
67
  formattingOptions: {
67
68
  insertSpaces: true,
package/build/codex.d.ts CHANGED
@@ -1,5 +1,6 @@
1
- export declare function writeCodexConfig(apiKey: string): {
1
+ export declare function writeCodexConfig(apiKey: string): Promise<{
2
2
  configPath: string;
3
3
  authPath: string;
4
- };
4
+ catalogPath: string;
5
+ }>;
5
6
  export declare function installCodexDeps(): Promise<void>;
package/build/codex.js CHANGED
@@ -8,10 +8,22 @@ const os = require("os");
8
8
  const TOML = require("@iarna/toml");
9
9
  const execa_1 = require("execa");
10
10
  const utils_1 = require("./utils");
11
+ const CODEX_BASE_URL = 'https://ai.gengjiawen.com/api/openai';
12
+ const CODEX_MODEL_CATALOG_CONFIG_PATH = '~/.codex/remote-model-catalog.json';
13
+ const CODEX_MODEL_CATALOG_FILENAME = 'remote-model-catalog.json';
11
14
  function getCodexConfigDir() {
12
15
  return path.join(os.homedir(), '.codex');
13
16
  }
14
- const CODEX_CONFIG_TOML_TEMPLATE = `model_provider = "jw"
17
+ function getCodexModelCatalogPath() {
18
+ return path.join(getCodexConfigDir(), CODEX_MODEL_CATALOG_FILENAME);
19
+ }
20
+ function getCodexModelCatalogConfigPath() {
21
+ return os.platform() === 'win32'
22
+ ? getCodexModelCatalogPath()
23
+ : CODEX_MODEL_CATALOG_CONFIG_PATH;
24
+ }
25
+ function getCodexConfigTomlTemplate() {
26
+ return `model_provider = "jw"
15
27
  model = "gpt-5.4"
16
28
  model_reasoning_effort = "high"
17
29
  plan_mode_reasoning_effort = "xhigh"
@@ -19,12 +31,14 @@ model_auto_compact_token_limit = 131072
19
31
  disable_response_storage = true
20
32
  preferred_auth_method = "apikey"
21
33
  service_tier = "fast"
34
+ model_catalog_json = ${JSON.stringify(getCodexModelCatalogConfigPath())}
22
35
 
23
36
  [model_providers.jw]
24
37
  name = "jw"
25
- base_url = "https://ai.gengjiawen.com/api/openai"
38
+ base_url = "${CODEX_BASE_URL}"
26
39
  wire_api = "responses"
27
40
  `;
41
+ }
28
42
  function isTomlTable(value) {
29
43
  return (value !== undefined &&
30
44
  typeof value === 'object' &&
@@ -44,21 +58,44 @@ function mergeTomlTables(existingConfig, templateConfig) {
44
58
  }
45
59
  function getMergedCodexConfig(existingContent) {
46
60
  const existingConfig = TOML.parse(existingContent);
47
- const templateConfig = TOML.parse(CODEX_CONFIG_TOML_TEMPLATE);
61
+ const templateConfig = TOML.parse(getCodexConfigTomlTemplate());
48
62
  return TOML.stringify(mergeTomlTables(existingConfig, templateConfig));
49
63
  }
50
- function writeCodexConfig(apiKey) {
64
+ async function refreshCodexModelCatalog(apiKey) {
65
+ if (typeof fetch !== 'function') {
66
+ throw new Error('Global fetch API is unavailable in this Node.js runtime.');
67
+ }
68
+ const response = await fetch(`${CODEX_BASE_URL}/models`, {
69
+ headers: {
70
+ Accept: 'application/json',
71
+ Authorization: `Bearer ${apiKey}`,
72
+ 'User-Agent': '@gengjiawen/os-init',
73
+ },
74
+ });
75
+ if (!response.ok) {
76
+ throw new Error(`Failed to refresh Codex model catalog: ${response.status} ${response.statusText}`);
77
+ }
78
+ const catalog = (await response.json());
79
+ if (!Array.isArray(catalog?.models) || catalog.models.length === 0) {
80
+ throw new Error('Failed to refresh Codex model catalog: response does not contain any models.');
81
+ }
82
+ const catalogPath = getCodexModelCatalogPath();
83
+ fs.writeFileSync(catalogPath, `${JSON.stringify(catalog, null, 2)}\n`);
84
+ return catalogPath;
85
+ }
86
+ async function writeCodexConfig(apiKey) {
51
87
  const configDir = getCodexConfigDir();
52
88
  (0, utils_1.ensureDir)(configDir);
89
+ const catalogPath = await refreshCodexModelCatalog(apiKey);
53
90
  const configPath = path.join(configDir, 'config.toml');
54
91
  const configContent = fs.existsSync(configPath)
55
92
  ? getMergedCodexConfig(fs.readFileSync(configPath, 'utf8'))
56
- : CODEX_CONFIG_TOML_TEMPLATE;
93
+ : getCodexConfigTomlTemplate();
57
94
  fs.writeFileSync(configPath, configContent);
58
95
  const authPath = path.join(configDir, 'auth.json');
59
96
  const authContent = JSON.stringify({ OPENAI_API_KEY: apiKey }, null, 2);
60
97
  fs.writeFileSync(authPath, authContent);
61
- return { configPath, authPath };
98
+ return { configPath, authPath, catalogPath };
62
99
  }
63
100
  async function installCodexDeps() {
64
101
  const packages = ['@openai/codex'];
package/build/opencode.js CHANGED
@@ -9,9 +9,31 @@ const execa_1 = require("execa");
9
9
  const utils_1 = require("./utils");
10
10
  const OPENCODE_PROVIDER_ID = 'MyCustomProvider';
11
11
  const OPENCODE_MODEL_ID = 'code';
12
- const OPENCODE_GLM_MODEL_ID = 'glm';
13
- const OPENCODE_KIMI_MODEL_ID = 'kimi';
12
+ const OPENCODE_MODEL_IDS = [
13
+ OPENCODE_MODEL_ID,
14
+ 'glm',
15
+ 'kimi',
16
+ 'minimax',
17
+ 'deepseek',
18
+ 'gemini-flash',
19
+ ];
14
20
  const OPENCODE_BASE_URL = 'https://ai.gengjiawen.com/api/openai/v1';
21
+ function createOpencodeModelConfig(modelId) {
22
+ return {
23
+ name: modelId,
24
+ attachment: true,
25
+ modalities: {
26
+ input: ['text', 'image'],
27
+ output: ['text'],
28
+ },
29
+ };
30
+ }
31
+ function createOpencodeModelsConfig() {
32
+ return Object.fromEntries(OPENCODE_MODEL_IDS.map((modelId) => [
33
+ modelId,
34
+ createOpencodeModelConfig(modelId),
35
+ ]));
36
+ }
15
37
  function getOpencodeConfigDir() {
16
38
  return path.join(os.homedir(), '.config', 'opencode');
17
39
  }
@@ -28,17 +50,7 @@ function writeOpencodeConfig(apiKey) {
28
50
  baseURL: OPENCODE_BASE_URL,
29
51
  apiKey,
30
52
  },
31
- models: {
32
- [OPENCODE_MODEL_ID]: {
33
- name: OPENCODE_MODEL_ID,
34
- },
35
- [OPENCODE_GLM_MODEL_ID]: {
36
- name: OPENCODE_GLM_MODEL_ID,
37
- },
38
- [OPENCODE_KIMI_MODEL_ID]: {
39
- name: OPENCODE_KIMI_MODEL_ID,
40
- },
41
- },
53
+ models: createOpencodeModelsConfig(),
42
54
  },
43
55
  },
44
56
  model: `${OPENCODE_PROVIDER_ID}/${OPENCODE_MODEL_ID}`,
@@ -12,12 +12,18 @@ describe('writeAllAgentsConfig', () => {
12
12
  let tempHome: string
13
13
  let homedirSpy: jest.SpiedFunction<typeof os.homedir>
14
14
  let originalAppData: string | undefined
15
+ let originalFetch: typeof global.fetch | undefined
15
16
 
16
17
  beforeEach(() => {
17
18
  tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'os-init-all-agents-'))
18
19
  homedirSpy = jest.spyOn(os, 'homedir').mockReturnValue(tempHome)
19
20
  originalAppData = process.env.APPDATA
21
+ originalFetch = global.fetch
20
22
  process.env.APPDATA = path.join(tempHome, 'AppData', 'Roaming')
23
+ global.fetch = jest.fn().mockResolvedValue({
24
+ ok: true,
25
+ json: async () => ({ models: [{ id: 'gpt-5.4' }] }),
26
+ }) as unknown as typeof global.fetch
21
27
  })
22
28
 
23
29
  afterEach(() => {
@@ -27,21 +33,27 @@ describe('writeAllAgentsConfig', () => {
27
33
  } else {
28
34
  process.env.APPDATA = originalAppData
29
35
  }
36
+ if (originalFetch === undefined) {
37
+ global.fetch = undefined as unknown as typeof global.fetch
38
+ } else {
39
+ global.fetch = originalFetch
40
+ }
30
41
  fs.rmSync(tempHome, { recursive: true, force: true })
31
42
  })
32
43
 
33
- test('writes Claude, Codex and OpenCode config by default', () => {
34
- const result = writeAllAgentsConfig('test-api-key')
44
+ test('writes Claude, Codex and OpenCode config by default', async () => {
45
+ const result = await writeAllAgentsConfig('test-api-key')
35
46
 
36
47
  expect(fs.existsSync(result.claude.settingsPath)).toBe(true)
37
48
  expect(fs.existsSync(result.codex.configPath)).toBe(true)
38
49
  expect(fs.existsSync(result.codex.authPath)).toBe(true)
50
+ expect(fs.existsSync(result.codex.catalogPath)).toBe(true)
39
51
  expect(fs.existsSync(result.opencode.configPath)).toBe(true)
40
52
  expect(result.gemini).toBeUndefined()
41
53
  })
42
54
 
43
- test('includes Gemini config when full option is enabled', () => {
44
- const result = writeAllAgentsConfig('test-api-key', { full: true })
55
+ test('includes Gemini config when full option is enabled', async () => {
56
+ const result = await writeAllAgentsConfig('test-api-key', { full: true })
45
57
 
46
58
  expect(result.gemini).toBeDefined()
47
59
  expect(fs.existsSync(result.gemini!.envPath)).toBe(true)
@@ -10,18 +10,18 @@ export interface AllAgentsOptions {
10
10
 
11
11
  export interface AllAgentsResult {
12
12
  claude: { settingsPath: string; vscodeSettingsPath: string }
13
- codex: { configPath: string; authPath: string }
13
+ codex: { configPath: string; authPath: string; catalogPath: string }
14
14
  opencode: { configPath: string }
15
15
  gemini?: { envPath: string; settingsPath: string }
16
16
  }
17
17
 
18
18
  /** Write configuration for the combined setup (Claude Code + Codex + OpenCode, optional Gemini CLI). */
19
- export function writeAllAgentsConfig(
19
+ export async function writeAllAgentsConfig(
20
20
  apiKey: string,
21
21
  options: AllAgentsOptions = {}
22
- ): AllAgentsResult {
22
+ ): Promise<AllAgentsResult> {
23
23
  const claudeResult = writeClaudeConfig(apiKey)
24
- const codexResult = writeCodexConfig(apiKey)
24
+ const codexResult = await writeCodexConfig(apiKey)
25
25
  const opencodeResult = writeOpencodeConfig(apiKey)
26
26
 
27
27
  const result: AllAgentsResult = {
@@ -71,12 +71,19 @@ describe('writeClaudeConfig', () => {
71
71
  expect(claudeSettings).toContain(
72
72
  '"ANTHROPIC_BASE_URL": "https://ai.gengjiawen.com/api/claude/"'
73
73
  )
74
+ expect(claudeSettings).toContain(
75
+ '"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1"'
76
+ )
74
77
 
75
78
  const vscodeSettings = fs.readFileSync(result.vscodeSettingsPath, 'utf8')
76
79
  expect(vscodeSettings).toContain('"editor.fontSize": 14')
77
80
  expect(vscodeSettings).toContain('"claudeCode.environmentVariables"')
78
81
  expect(vscodeSettings).toContain('"ANTHROPIC_BASE_URL"')
79
82
  expect(vscodeSettings).toContain('"ANTHROPIC_AUTH_TOKEN"')
83
+ expect(vscodeSettings).toContain(
84
+ '"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"'
85
+ )
86
+ expect(vscodeSettings).toContain('"1"')
80
87
  expect(vscodeSettings).toContain('"test-api-key"')
81
88
  })
82
89
 
@@ -94,6 +94,7 @@ function writeVSCodeClaudePluginConfig(apiKey: string): {
94
94
  [
95
95
  { name: 'ANTHROPIC_BASE_URL', value: CLAUDE_BASE_URL },
96
96
  { name: 'ANTHROPIC_AUTH_TOKEN', value: apiKey },
97
+ { name: 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC', value: '1' },
97
98
  ],
98
99
  {
99
100
  formattingOptions: {
@@ -12,27 +12,57 @@ import { writeCodexConfig } from './codex'
12
12
  describe('writeCodexConfig', () => {
13
13
  let tempHome: string
14
14
  let homedirSpy: jest.SpiedFunction<typeof os.homedir>
15
+ let originalFetch: typeof global.fetch | undefined
16
+
17
+ function getExpectedModelCatalogConfigPath(): string {
18
+ return os.platform() === 'win32'
19
+ ? path.join(tempHome, '.codex', 'remote-model-catalog.json')
20
+ : '~/.codex/remote-model-catalog.json'
21
+ }
22
+
23
+ function mockCatalogFetch(
24
+ catalog: { models: Array<Record<string, unknown>> } = {
25
+ models: [{ id: 'gpt-5.4' }],
26
+ }
27
+ ): jest.Mock {
28
+ const fetchMock = jest.fn().mockResolvedValue({
29
+ ok: true,
30
+ json: async () => catalog,
31
+ })
32
+ global.fetch = fetchMock as unknown as typeof global.fetch
33
+ return fetchMock
34
+ }
15
35
 
16
36
  beforeEach(() => {
17
37
  tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'os-init-codex-'))
18
38
  homedirSpy = jest.spyOn(os, 'homedir').mockReturnValue(tempHome)
39
+ originalFetch = global.fetch
40
+ mockCatalogFetch()
19
41
  })
20
42
 
21
43
  afterEach(() => {
22
44
  homedirSpy.mockRestore()
45
+ if (originalFetch === undefined) {
46
+ global.fetch = undefined as unknown as typeof global.fetch
47
+ } else {
48
+ global.fetch = originalFetch
49
+ }
23
50
  fs.rmSync(tempHome, { recursive: true, force: true })
24
51
  })
25
52
 
26
- test('writes config with 128k auto compact threshold', () => {
27
- const result = writeCodexConfig('test-api-key')
53
+ test('writes config with 128k auto compact threshold', async () => {
54
+ const result = await writeCodexConfig('test-api-key')
28
55
  const config = TOML.parse(fs.readFileSync(result.configPath, 'utf8')) as {
29
56
  model_auto_compact_token_limit: number
57
+ model_catalog_json: string
30
58
  }
31
59
 
32
60
  expect(config.model_auto_compact_token_limit).toBe(131072)
61
+ expect(config.model_catalog_json).toBe(getExpectedModelCatalogConfigPath())
62
+ expect(fs.existsSync(result.catalogPath)).toBe(true)
33
63
  })
34
64
 
35
- test('merges template keys and keeps custom config', () => {
65
+ test('merges template keys and keeps custom config', async () => {
36
66
  const configDir = path.join(tempHome, '.codex')
37
67
  fs.mkdirSync(configDir, { recursive: true })
38
68
  const configPath = path.join(configDir, 'config.toml')
@@ -48,11 +78,12 @@ custom_model = "keep-me"
48
78
  `
49
79
  )
50
80
 
51
- writeCodexConfig('test-api-key')
81
+ await writeCodexConfig('test-api-key')
52
82
  const config = TOML.parse(fs.readFileSync(configPath, 'utf8')) as {
53
83
  service_tier: string
54
84
  custom_flag: boolean
55
85
  model: string
86
+ model_catalog_json: string
56
87
  preferred_auth_method: string
57
88
  model_providers: {
58
89
  jw: {
@@ -66,6 +97,7 @@ custom_model = "keep-me"
66
97
  expect(config.service_tier).toBe('fast')
67
98
  expect(config.custom_flag).toBe(true)
68
99
  expect(config.model).toBe('gpt-5.4')
100
+ expect(config.model_catalog_json).toBe(getExpectedModelCatalogConfigPath())
69
101
  expect(config.preferred_auth_method).toBe('apikey')
70
102
  expect(config.model_providers.jw.base_url).toBe(
71
103
  'https://ai.gengjiawen.com/api/openai'
@@ -74,7 +106,7 @@ custom_model = "keep-me"
74
106
  expect(config.model_providers.jw.name).toBe('jw')
75
107
  })
76
108
 
77
- test('adds missing keys without removing custom config', () => {
109
+ test('adds missing keys without removing custom config', async () => {
78
110
  const configDir = path.join(tempHome, '.codex')
79
111
  fs.mkdirSync(configDir, { recursive: true })
80
112
  const configPath = path.join(configDir, 'config.toml')
@@ -88,9 +120,10 @@ base_url = "https://example.com"
88
120
  `
89
121
  )
90
122
 
91
- writeCodexConfig('test-api-key')
123
+ await writeCodexConfig('test-api-key')
92
124
  const config = TOML.parse(fs.readFileSync(configPath, 'utf8')) as {
93
125
  model: string
126
+ model_catalog_json: string
94
127
  preferred_auth_method: string
95
128
  service_tier: string
96
129
  model_providers: {
@@ -102,8 +135,83 @@ base_url = "https://example.com"
102
135
  }
103
136
 
104
137
  expect(config.model).toBe('gpt-5.4')
138
+ expect(config.model_catalog_json).toBe(getExpectedModelCatalogConfigPath())
105
139
  expect(config.preferred_auth_method).toBe('apikey')
106
140
  expect(config.model_providers.jw.name).toBe('jw')
107
141
  expect(config.service_tier).toBe('fast')
108
142
  })
143
+
144
+ test('refreshes remote model catalog after writing config', async () => {
145
+ const fetchMock = mockCatalogFetch({
146
+ models: [
147
+ { id: 'gpt-5.4' },
148
+ { id: 'gpt-5.4-mini' },
149
+ { id: 'gpt-5.3-codex' },
150
+ ],
151
+ })
152
+
153
+ const result = await writeCodexConfig('test-api-key')
154
+
155
+ expect(fetchMock).toHaveBeenCalledWith(
156
+ 'https://ai.gengjiawen.com/api/openai/models',
157
+ {
158
+ headers: {
159
+ Accept: 'application/json',
160
+ Authorization: 'Bearer test-api-key',
161
+ 'User-Agent': '@gengjiawen/os-init',
162
+ },
163
+ }
164
+ )
165
+
166
+ const catalog = JSON.parse(fs.readFileSync(result.catalogPath, 'utf8')) as {
167
+ models: Array<{ id: string }>
168
+ }
169
+
170
+ expect(catalog).toEqual({
171
+ models: [
172
+ { id: 'gpt-5.4' },
173
+ { id: 'gpt-5.4-mini' },
174
+ { id: 'gpt-5.3-codex' },
175
+ ],
176
+ })
177
+ })
178
+
179
+ test('writes an absolute model catalog path on Windows', async () => {
180
+ const platformSpy = jest.spyOn(os, 'platform').mockReturnValue('win32')
181
+
182
+ try {
183
+ const result = await writeCodexConfig('test-api-key')
184
+ const config = TOML.parse(fs.readFileSync(result.configPath, 'utf8')) as {
185
+ model_catalog_json: string
186
+ }
187
+
188
+ expect(config.model_catalog_json).toBe(
189
+ path.join(tempHome, '.codex', 'remote-model-catalog.json')
190
+ )
191
+ } finally {
192
+ platformSpy.mockRestore()
193
+ }
194
+ })
195
+
196
+ test('throws when refreshing the remote model catalog fails', async () => {
197
+ global.fetch = jest.fn().mockResolvedValue({
198
+ ok: false,
199
+ status: 503,
200
+ statusText: 'Service Unavailable',
201
+ }) as unknown as typeof global.fetch
202
+
203
+ await expect(writeCodexConfig('test-api-key')).rejects.toThrow(
204
+ 'Failed to refresh Codex model catalog: 503 Service Unavailable'
205
+ )
206
+
207
+ expect(fs.existsSync(path.join(tempHome, '.codex', 'config.toml'))).toBe(
208
+ false
209
+ )
210
+ expect(fs.existsSync(path.join(tempHome, '.codex', 'auth.json'))).toBe(
211
+ false
212
+ )
213
+ expect(
214
+ fs.existsSync(path.join(tempHome, '.codex', 'remote-model-catalog.json'))
215
+ ).toBe(false)
216
+ })
109
217
  })
package/libs/codex.ts CHANGED
@@ -6,14 +6,29 @@ import { execa } from 'execa'
6
6
  import { ensureDir, commandExists, PNPM_INSTALL_ENV } from './utils'
7
7
 
8
8
  type TomlTable = ReturnType<typeof TOML.parse>
9
+ type CodexModelCatalog = { models: unknown[] }
10
+
11
+ const CODEX_BASE_URL = 'https://ai.gengjiawen.com/api/openai'
12
+ const CODEX_MODEL_CATALOG_CONFIG_PATH = '~/.codex/remote-model-catalog.json'
13
+ const CODEX_MODEL_CATALOG_FILENAME = 'remote-model-catalog.json'
9
14
 
10
15
  /** Return Codex configuration directory path */
11
16
  function getCodexConfigDir(): string {
12
17
  return path.join(os.homedir(), '.codex')
13
18
  }
14
19
 
15
- /** Template for Codex config.toml */
16
- const CODEX_CONFIG_TOML_TEMPLATE = `model_provider = "jw"
20
+ function getCodexModelCatalogPath(): string {
21
+ return path.join(getCodexConfigDir(), CODEX_MODEL_CATALOG_FILENAME)
22
+ }
23
+
24
+ function getCodexModelCatalogConfigPath(): string {
25
+ return os.platform() === 'win32'
26
+ ? getCodexModelCatalogPath()
27
+ : CODEX_MODEL_CATALOG_CONFIG_PATH
28
+ }
29
+
30
+ function getCodexConfigTomlTemplate(): string {
31
+ return `model_provider = "jw"
17
32
  model = "gpt-5.4"
18
33
  model_reasoning_effort = "high"
19
34
  plan_mode_reasoning_effort = "xhigh"
@@ -21,12 +36,14 @@ model_auto_compact_token_limit = 131072
21
36
  disable_response_storage = true
22
37
  preferred_auth_method = "apikey"
23
38
  service_tier = "fast"
39
+ model_catalog_json = ${JSON.stringify(getCodexModelCatalogConfigPath())}
24
40
 
25
41
  [model_providers.jw]
26
42
  name = "jw"
27
- base_url = "https://ai.gengjiawen.com/api/openai"
43
+ base_url = "${CODEX_BASE_URL}"
28
44
  wire_api = "responses"
29
45
  `
46
+ }
30
47
 
31
48
  function isTomlTable(value: unknown): value is TomlTable {
32
49
  return (
@@ -57,30 +74,66 @@ function mergeTomlTables(
57
74
 
58
75
  function getMergedCodexConfig(existingContent: string): string {
59
76
  const existingConfig = TOML.parse(existingContent) as TomlTable
60
- const templateConfig = TOML.parse(CODEX_CONFIG_TOML_TEMPLATE) as TomlTable
77
+ const templateConfig = TOML.parse(getCodexConfigTomlTemplate()) as TomlTable
61
78
 
62
79
  return TOML.stringify(mergeTomlTables(existingConfig, templateConfig))
63
80
  }
64
81
 
82
+ async function refreshCodexModelCatalog(apiKey: string): Promise<string> {
83
+ if (typeof fetch !== 'function') {
84
+ throw new Error('Global fetch API is unavailable in this Node.js runtime.')
85
+ }
86
+
87
+ const response = await fetch(`${CODEX_BASE_URL}/models`, {
88
+ headers: {
89
+ Accept: 'application/json',
90
+ Authorization: `Bearer ${apiKey}`,
91
+ 'User-Agent': '@gengjiawen/os-init',
92
+ },
93
+ })
94
+
95
+ if (!response.ok) {
96
+ throw new Error(
97
+ `Failed to refresh Codex model catalog: ${response.status} ${response.statusText}`
98
+ )
99
+ }
100
+
101
+ const catalog = (await response.json()) as CodexModelCatalog
102
+
103
+ if (!Array.isArray(catalog?.models) || catalog.models.length === 0) {
104
+ throw new Error(
105
+ 'Failed to refresh Codex model catalog: response does not contain any models.'
106
+ )
107
+ }
108
+
109
+ const catalogPath = getCodexModelCatalogPath()
110
+ fs.writeFileSync(catalogPath, `${JSON.stringify(catalog, null, 2)}\n`)
111
+
112
+ return catalogPath
113
+ }
114
+
65
115
  /** Write Codex config.toml and auth.json */
66
- export function writeCodexConfig(apiKey: string): {
116
+ export async function writeCodexConfig(apiKey: string): Promise<{
67
117
  configPath: string
68
118
  authPath: string
69
- } {
119
+ catalogPath: string
120
+ }> {
70
121
  const configDir = getCodexConfigDir()
71
122
  ensureDir(configDir)
72
123
 
124
+ const catalogPath = await refreshCodexModelCatalog(apiKey)
125
+
73
126
  const configPath = path.join(configDir, 'config.toml')
74
127
  const configContent = fs.existsSync(configPath)
75
128
  ? getMergedCodexConfig(fs.readFileSync(configPath, 'utf8'))
76
- : CODEX_CONFIG_TOML_TEMPLATE
129
+ : getCodexConfigTomlTemplate()
77
130
  fs.writeFileSync(configPath, configContent)
78
131
 
79
132
  const authPath = path.join(configDir, 'auth.json')
80
133
  const authContent = JSON.stringify({ OPENAI_API_KEY: apiKey }, null, 2)
81
134
  fs.writeFileSync(authPath, authContent)
82
135
 
83
- return { configPath, authPath }
136
+ return { configPath, authPath, catalogPath }
84
137
  }
85
138
 
86
139
  /** Install Codex dependency */
@@ -12,6 +12,15 @@ describe('writeOpencodeConfig', () => {
12
12
  let tempHome: string
13
13
  let homedirSpy: jest.SpiedFunction<typeof os.homedir>
14
14
 
15
+ const expectedModelConfig = (name: string) => ({
16
+ name,
17
+ attachment: true,
18
+ modalities: {
19
+ input: ['text', 'image'],
20
+ output: ['text'],
21
+ },
22
+ })
23
+
15
24
  beforeEach(() => {
16
25
  tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'os-init-opencode-'))
17
26
  homedirSpy = jest.spyOn(os, 'homedir').mockReturnValue(tempHome)
@@ -40,15 +49,24 @@ describe('writeOpencodeConfig', () => {
40
49
  'https://ai.gengjiawen.com/api/openai/v1'
41
50
  )
42
51
  expect(config.provider.MyCustomProvider.options.apiKey).toBe('test-api-key')
43
- expect(config.provider.MyCustomProvider.models.code).toEqual({
44
- name: 'code',
45
- })
46
- expect(config.provider.MyCustomProvider.models.glm).toEqual({
47
- name: 'glm',
48
- })
49
- expect(config.provider.MyCustomProvider.models.kimi).toEqual({
50
- name: 'kimi',
51
- })
52
+ expect(config.provider.MyCustomProvider.models.code).toEqual(
53
+ expectedModelConfig('code')
54
+ )
55
+ expect(config.provider.MyCustomProvider.models.glm).toEqual(
56
+ expectedModelConfig('glm')
57
+ )
58
+ expect(config.provider.MyCustomProvider.models.kimi).toEqual(
59
+ expectedModelConfig('kimi')
60
+ )
61
+ expect(config.provider.MyCustomProvider.models.minimax).toEqual(
62
+ expectedModelConfig('minimax')
63
+ )
64
+ expect(config.provider.MyCustomProvider.models.deepseek).toEqual(
65
+ expectedModelConfig('deepseek')
66
+ )
67
+ expect(config.provider.MyCustomProvider.models['gemini-flash']).toEqual(
68
+ expectedModelConfig('gemini-flash')
69
+ )
52
70
  expect(config.model).toBe('MyCustomProvider/code')
53
71
  expect(config.small_model).toBe('MyCustomProvider/code')
54
72
  })
package/libs/opencode.ts CHANGED
@@ -6,10 +6,36 @@ import { commandExists, ensureDir, PNPM_INSTALL_ENV } from './utils'
6
6
 
7
7
  const OPENCODE_PROVIDER_ID = 'MyCustomProvider'
8
8
  const OPENCODE_MODEL_ID = 'code'
9
- const OPENCODE_GLM_MODEL_ID = 'glm'
10
- const OPENCODE_KIMI_MODEL_ID = 'kimi'
9
+ const OPENCODE_MODEL_IDS = [
10
+ OPENCODE_MODEL_ID,
11
+ 'glm',
12
+ 'kimi',
13
+ 'minimax',
14
+ 'deepseek',
15
+ 'gemini-flash',
16
+ ]
11
17
  const OPENCODE_BASE_URL = 'https://ai.gengjiawen.com/api/openai/v1'
12
18
 
19
+ function createOpencodeModelConfig(modelId: string) {
20
+ return {
21
+ name: modelId,
22
+ attachment: true,
23
+ modalities: {
24
+ input: ['text', 'image'],
25
+ output: ['text'],
26
+ },
27
+ }
28
+ }
29
+
30
+ function createOpencodeModelsConfig() {
31
+ return Object.fromEntries(
32
+ OPENCODE_MODEL_IDS.map((modelId) => [
33
+ modelId,
34
+ createOpencodeModelConfig(modelId),
35
+ ])
36
+ )
37
+ }
38
+
13
39
  /** Return OpenCode configuration directory path */
14
40
  function getOpencodeConfigDir(): string {
15
41
  return path.join(os.homedir(), '.config', 'opencode')
@@ -30,17 +56,7 @@ export function writeOpencodeConfig(apiKey: string): { configPath: string } {
30
56
  baseURL: OPENCODE_BASE_URL,
31
57
  apiKey,
32
58
  },
33
- models: {
34
- [OPENCODE_MODEL_ID]: {
35
- name: OPENCODE_MODEL_ID,
36
- },
37
- [OPENCODE_GLM_MODEL_ID]: {
38
- name: OPENCODE_GLM_MODEL_ID,
39
- },
40
- [OPENCODE_KIMI_MODEL_ID]: {
41
- name: OPENCODE_KIMI_MODEL_ID,
42
- },
43
- },
59
+ models: createOpencodeModelsConfig(),
44
60
  },
45
61
  },
46
62
  model: `${OPENCODE_PROVIDER_ID}/${OPENCODE_MODEL_ID}`,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@gengjiawen/os-init",
3
3
  "private": false,
4
- "version": "1.18.1",
4
+ "version": "1.20.0",
5
5
  "description": "",
6
6
  "main": "index.js",
7
7
  "bin": {