@koreanhole/opencodex 2.96.0 → 2.97.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/AGENTS_INSTALL.md +14 -2
- package/README.md +35 -3
- package/package.json +2 -1
- package/src/adapters/cli-agent/gemini/acp.ts +28 -1
- package/src/adapters/cli-agent/gemini/adapter.ts +32 -1
- package/src/adapters/cli-agent/gemini/live-models.ts +24 -4
- package/src/adapters/cli-agent/gemini/model-metadata.ts +291 -0
- package/src/cli/index.ts +43 -1
- package/src/lib/codex-catalog.ts +318 -0
- package/src/server/index.ts +19 -5
- package/src/server/responses.ts +29 -5
- package/src/types.ts +13 -0
package/AGENTS_INSTALL.md
CHANGED
|
@@ -23,11 +23,17 @@ To use Gemini instead, authenticate it through its official CLI and select the
|
|
|
23
23
|
provider:
|
|
24
24
|
|
|
25
25
|
```bash
|
|
26
|
-
gemini #
|
|
26
|
+
gemini # configure a Gemini Developer API or Vertex AI API key
|
|
27
27
|
ocx provider add gemini-cli --set-default
|
|
28
28
|
ocx start
|
|
29
|
+
ocx codex sync
|
|
29
30
|
```
|
|
30
31
|
|
|
32
|
+
`ocx codex sync` refreshes the Codex app catalog with locally discovered Gemini
|
|
33
|
+
models and reasoning levels. Restart the Codex app/app-server after refresh. The
|
|
34
|
+
catalog file is written only when `model_catalog_json` names an opencodex catalog
|
|
35
|
+
or through the documented default path; unrelated Codex settings are not changed.
|
|
36
|
+
|
|
31
37
|
## Credential rules
|
|
32
38
|
|
|
33
39
|
- Use the operator's existing Cline or Gemini CLI sign-in. Do not copy vendor
|
|
@@ -37,7 +43,12 @@ ocx start
|
|
|
37
43
|
- Do not edit the operator's real Cline provider store unless the operator asks.
|
|
38
44
|
- Do not import or replay Gemini OAuth/API-key material; the Gemini CLI remains
|
|
39
45
|
the authentication authority.
|
|
40
|
-
-
|
|
46
|
+
- Third-party use must use a Gemini Developer API key or Vertex AI GenAI API key.
|
|
47
|
+
Gemini CLI's official FAQ identifies third-party use of its personal Google
|
|
48
|
+
OAuth authentication as a terms violation. OAuth is not a supported
|
|
49
|
+
opencodex setup path.
|
|
50
|
+
- The proxy writes under `$OPENCODEX_HOME` (default `~/.opencodex`) and may update
|
|
51
|
+
the active opencodex Codex model catalog selected by `model_catalog_json`.
|
|
41
52
|
|
|
42
53
|
## Operation
|
|
43
54
|
|
|
@@ -59,6 +70,7 @@ bun run typecheck
|
|
|
59
70
|
bun test tests/e2e/cline-only.test.ts
|
|
60
71
|
bun run test:e2e:gemini
|
|
61
72
|
bun run test:e2e:gemini-tool
|
|
73
|
+
bun run test:e2e:gemini-reasoning
|
|
62
74
|
```
|
|
63
75
|
|
|
64
76
|
Report whether each evidence path used a deterministic ACP fixture or a real
|
package/README.md
CHANGED
|
@@ -17,13 +17,19 @@ npm install -g cline
|
|
|
17
17
|
cline auth
|
|
18
18
|
|
|
19
19
|
npm install -g @google/gemini-cli
|
|
20
|
-
gemini #
|
|
20
|
+
gemini # configure GEMINI_API_KEY before using it through opencodex
|
|
21
21
|
```
|
|
22
22
|
|
|
23
23
|
Gemini authentication remains in Gemini CLI's own home/settings. The proxy never
|
|
24
24
|
copies, logs, or stores an API key, OAuth token, bearer token, or full request
|
|
25
25
|
body.
|
|
26
26
|
|
|
27
|
+
For third-party clients such as opencodex, use a Gemini Developer API key
|
|
28
|
+
(Google AI Studio) or a Vertex AI GenAI API key. Do not use Gemini CLI's
|
|
29
|
+
personal Google OAuth flow: Gemini CLI's official FAQ identifies third-party
|
|
30
|
+
software piggybacking on its OAuth authentication as a terms violation and says
|
|
31
|
+
the supported third-party method is an API key.
|
|
32
|
+
|
|
27
33
|
## Install
|
|
28
34
|
|
|
29
35
|
```bash
|
|
@@ -56,6 +62,7 @@ name = "opencodex (Cline or Gemini CLI)"
|
|
|
56
62
|
base_url = "http://127.0.0.1:10100/v1"
|
|
57
63
|
wire_api = "responses"
|
|
58
64
|
requires_openai_auth = false
|
|
65
|
+
model_catalog_url = "http://127.0.0.1:10100/v1/model-catalog"
|
|
59
66
|
```
|
|
60
67
|
|
|
61
68
|
The model list is live. Cline ACP models are exposed as `cline-cli/<encoded-id>`;
|
|
@@ -63,6 +70,20 @@ Gemini ACP models are exposed as `gemini-cli/<encoded-id>`. Each provider keeps
|
|
|
63
70
|
`default` selector. A provider prefix routes only to that vendor; unprefixed
|
|
64
71
|
`default` uses the configured default provider.
|
|
65
72
|
|
|
73
|
+
Gemini model IDs and reasoning ladders are discovered from the installed Gemini
|
|
74
|
+
CLI. Thinking-capable models expose the locally supported levels; non-thinking
|
|
75
|
+
models do not get an invented ladder. For a running Codex app, refresh the active
|
|
76
|
+
catalog and reload the app/app-server:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
bun run src/cli/index.ts codex sync
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
The command updates the root `model_catalog_json` target when it names an
|
|
83
|
+
opencodex catalog, otherwise it writes `$CODEX_HOME/opencodex-catalog.json` and
|
|
84
|
+
prints the activation setting. `model_catalog_url` avoids the file when the app
|
|
85
|
+
catalog is fetched dynamically.
|
|
86
|
+
|
|
66
87
|
## Claude Code
|
|
67
88
|
|
|
68
89
|
```bash
|
|
@@ -114,6 +135,7 @@ bun test tests
|
|
|
114
135
|
bun run test:e2e
|
|
115
136
|
bun run test:e2e:gemini
|
|
116
137
|
bun run test:e2e:gemini-tool
|
|
138
|
+
bun run test:e2e:gemini-reasoning
|
|
117
139
|
bun run privacy:scan
|
|
118
140
|
bun run structure:check
|
|
119
141
|
```
|
|
@@ -131,6 +153,11 @@ Codex's `exec_command`, Codex executes a fixed read-only `printf`, and Gemini
|
|
|
131
153
|
returns the final answer. Both commands use no retries. Missing binaries,
|
|
132
154
|
authentication, or a Flash Lite model cause a hard failure.
|
|
133
155
|
|
|
156
|
+
`test:e2e:gemini-reasoning` discovers a thinking-capable model and level from the
|
|
157
|
+
local Gemini CLI, validates the refreshed Codex catalog, and runs one minimal
|
|
158
|
+
reasoning-enabled turn. It fails when the authenticated account cannot execute
|
|
159
|
+
the discovered model.
|
|
160
|
+
|
|
134
161
|
Optional overrides are `CLINE_BIN_PATH`, `GEMINI_BIN_PATH`, and `CODEX_BIN_PATH`.
|
|
135
162
|
|
|
136
163
|
## Troubleshooting
|
|
@@ -141,9 +168,14 @@ Optional overrides are `CLINE_BIN_PATH`, `GEMINI_BIN_PATH`, and `CODEX_BIN_PATH`
|
|
|
141
168
|
the model is `default`; native model selection requires ACP.
|
|
142
169
|
- **Gemini not found**: install it and verify `gemini --help` works, or set
|
|
143
170
|
`GEMINI_BIN_PATH`.
|
|
144
|
-
- **Gemini authentication required**: configure Gemini
|
|
145
|
-
key; do not
|
|
171
|
+
- **Gemini authentication required**: configure a Gemini Developer API key or
|
|
172
|
+
Vertex AI API key for Gemini CLI; do not use personal Google OAuth with this
|
|
173
|
+
third-party proxy and do not pass the key to opencodex.
|
|
146
174
|
- **Gemini ACP unavailable**: upgrade Gemini CLI to a version advertising `--acp`.
|
|
175
|
+
- **Gemini Pro quota exhausted**: reasoning-capable models may require paid Pro
|
|
176
|
+
quota; the catalog stays accurate but that model cannot complete a live turn.
|
|
177
|
+
- **Codex model picker is stale**: run `ocx codex sync`, then restart the Codex
|
|
178
|
+
app/app-server.
|
|
147
179
|
- **Port in use**: choose another port with `ocx start --port <port>`.
|
|
148
180
|
- **Stale runtime state**: run `ocx stop`; it removes stale state only when the
|
|
149
181
|
recorded process and health endpoint cannot be verified.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@koreanhole/opencodex",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.97.0",
|
|
4
4
|
"description": "Cline and Gemini CLI provider proxy for OpenAI Codex and Claude Code",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./bin/package-main.mjs",
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
"test:e2e": "bun test tests/e2e/cline-only.test.ts",
|
|
33
33
|
"test:e2e:gemini": "bun test ./tests/e2e/gemini-codex.live.ts -t \"one minimal Flash Lite completion\"",
|
|
34
34
|
"test:e2e:gemini-tool": "bun test ./tests/e2e/gemini-codex.live.ts -t \"one short Gemini tool call\"",
|
|
35
|
+
"test:e2e:gemini-reasoning": "bun test ./tests/e2e/gemini-reasoning.live.ts",
|
|
35
36
|
"typecheck": "bun check --no-script --noEmit",
|
|
36
37
|
"privacy:scan": "bun scripts/privacy-scan.ts",
|
|
37
38
|
"structure:check": "bun scripts/structure-check.ts",
|
|
@@ -203,8 +203,35 @@ export interface GeminiAcpWorkspace {
|
|
|
203
203
|
cleanup: () => Promise<void>;
|
|
204
204
|
}
|
|
205
205
|
|
|
206
|
+
export interface GeminiReasoningSelection {
|
|
207
|
+
modelId: string;
|
|
208
|
+
thinkingConfig: {
|
|
209
|
+
thinkingLevel?: string;
|
|
210
|
+
thinkingBudget?: number;
|
|
211
|
+
includeThoughts?: boolean;
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function geminiSystemSettings(reasoning?: GeminiReasoningSelection): string {
|
|
216
|
+
const settings = JSON.parse(GEMINI_SYSTEM_SETTINGS) as Record<string, unknown>;
|
|
217
|
+
if (reasoning) {
|
|
218
|
+
settings.modelConfigs = {
|
|
219
|
+
customOverrides: [{
|
|
220
|
+
match: { model: reasoning.modelId },
|
|
221
|
+
modelConfig: {
|
|
222
|
+
generateContentConfig: {
|
|
223
|
+
thinkingConfig: reasoning.thinkingConfig,
|
|
224
|
+
},
|
|
225
|
+
},
|
|
226
|
+
}],
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
return `${JSON.stringify(settings, null, 2)}\n`;
|
|
230
|
+
}
|
|
231
|
+
|
|
206
232
|
export async function stageGeminiAcpWorkspace(
|
|
207
233
|
catalog: readonly Record<string, unknown>[] = [],
|
|
234
|
+
reasoning?: GeminiReasoningSelection,
|
|
208
235
|
): Promise<GeminiAcpWorkspace> {
|
|
209
236
|
const cwd = await mkdtemp(join(tmpdir(), "ocx-gemini-acp-"));
|
|
210
237
|
const policyPath = join(cwd, "policy.toml");
|
|
@@ -213,7 +240,7 @@ export async function stageGeminiAcpWorkspace(
|
|
|
213
240
|
const capturePath = join(cwd, "tool-intents.ndjson");
|
|
214
241
|
await Promise.all([
|
|
215
242
|
writeFile(policyPath, GEMINI_POLICY, { encoding: "utf8", mode: 0o600, flag: "wx" }),
|
|
216
|
-
writeFile(settingsPath,
|
|
243
|
+
writeFile(settingsPath, geminiSystemSettings(reasoning), { encoding: "utf8", mode: 0o600, flag: "wx" }),
|
|
217
244
|
writeFile(catalogPath, JSON.stringify(catalog), { encoding: "utf8", mode: 0o600, flag: "wx" }),
|
|
218
245
|
...(catalog.length > 0
|
|
219
246
|
? [writeFile(capturePath, "", { encoding: "utf8", mode: 0o600, flag: "wx" })]
|
|
@@ -7,6 +7,11 @@ import { isStandaloneBinary } from "../../../lib/standalone";
|
|
|
7
7
|
import type { CliAgentTurnDeps } from "../types";
|
|
8
8
|
import { ToolIntentError } from "../tool-intent";
|
|
9
9
|
import { resolveGeminiBinary } from "./binary";
|
|
10
|
+
import {
|
|
11
|
+
discoverGeminiModelMetadata,
|
|
12
|
+
reasoningLevelForEffort,
|
|
13
|
+
type GeminiMetadataResult,
|
|
14
|
+
} from "./model-metadata";
|
|
10
15
|
import {
|
|
11
16
|
GeminiAcpError,
|
|
12
17
|
buildGeminiAcpEnv,
|
|
@@ -35,6 +40,7 @@ export interface GeminiTurnDeps extends CliAgentTurnDeps {
|
|
|
35
40
|
env?: NodeJS.ProcessEnv;
|
|
36
41
|
probeAcp?: typeof probeGeminiAcpSupport;
|
|
37
42
|
stageAcp?: typeof stageGeminiAcpWorkspace;
|
|
43
|
+
metadata?: typeof discoverGeminiModelMetadata;
|
|
38
44
|
runAcp?: (input: GeminiAcpTurnInput, emit: (event: AdapterEvent) => void, deps?: GeminiAcpDeps) => Promise<void>;
|
|
39
45
|
acpDeps?: GeminiAcpDeps;
|
|
40
46
|
}
|
|
@@ -159,6 +165,28 @@ export async function runGeminiTurn(
|
|
|
159
165
|
return;
|
|
160
166
|
}
|
|
161
167
|
|
|
168
|
+
let metadata: GeminiMetadataResult | undefined;
|
|
169
|
+
let reasoning;
|
|
170
|
+
try {
|
|
171
|
+
metadata = await (deps.metadata ?? discoverGeminiModelMetadata)({ binary, env: sourceEnv });
|
|
172
|
+
const capability = metadata.capabilities[requestedModel];
|
|
173
|
+
reasoning = reasoningLevelForEffort(capability, parsed.options.reasoning);
|
|
174
|
+
if (capability?.thinkingSupported && parsed.options.reasoning && !reasoning) {
|
|
175
|
+
emit({
|
|
176
|
+
type: "error",
|
|
177
|
+
message: `Gemini model "${requestedModel}" does not support reasoning effort "${parsed.options.reasoning}". Advertised levels: ${capability.reasoningLevels.map(level => level.effort).join(", ") || "none"}.`,
|
|
178
|
+
status: 400,
|
|
179
|
+
errorType: "invalid_request_error",
|
|
180
|
+
code: "reasoning_effort_unsupported",
|
|
181
|
+
retryable: false,
|
|
182
|
+
});
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
} catch {
|
|
186
|
+
metadata = undefined;
|
|
187
|
+
reasoning = undefined;
|
|
188
|
+
}
|
|
189
|
+
|
|
162
190
|
const acpDeps: GeminiAcpDeps = {
|
|
163
191
|
...deps.acpDeps,
|
|
164
192
|
env: sourceEnv,
|
|
@@ -183,7 +211,10 @@ export async function runGeminiTurn(
|
|
|
183
211
|
|
|
184
212
|
const vendorNames = toolNameMap(catalog);
|
|
185
213
|
const vendorNameByWire = new Map([...catalog.emittedNameMap.values()].map(wireName => [wireName, wireName]));
|
|
186
|
-
const workspace = await (deps.stageAcp ?? stageGeminiAcpWorkspace)(
|
|
214
|
+
const workspace = await (deps.stageAcp ?? stageGeminiAcpWorkspace)(
|
|
215
|
+
catalog.tools,
|
|
216
|
+
reasoning ? { modelId: requestedModel, thinkingConfig: reasoning.thinkingConfig } : undefined,
|
|
217
|
+
);
|
|
187
218
|
try {
|
|
188
219
|
const turnEnv = { ...acpEnv, GEMINI_CLI_SYSTEM_SETTINGS_PATH: workspace.settingsPath };
|
|
189
220
|
const mcpServers = workspace.capturePath && catalog.emittedNameMap.size > 0
|
|
@@ -12,6 +12,8 @@ import {
|
|
|
12
12
|
probeGeminiAcpSupport,
|
|
13
13
|
stageGeminiAcpWorkspace,
|
|
14
14
|
} from "./acp";
|
|
15
|
+
import { discoverGeminiModelMetadata, mergeGeminiModelDescriptors } from "./model-metadata";
|
|
16
|
+
import type { OcxProviderModel } from "../../../types";
|
|
15
17
|
|
|
16
18
|
export type GeminiLiveModelsFailure = "not_found" | "unsupported" | "auth_required" | "backoff" | "transport";
|
|
17
19
|
|
|
@@ -19,9 +21,11 @@ export type GeminiLiveModelsResult =
|
|
|
19
21
|
| {
|
|
20
22
|
ok: true;
|
|
21
23
|
models: string[];
|
|
24
|
+
modelInfo: OcxProviderModel[];
|
|
22
25
|
source: "models" | "default";
|
|
23
26
|
fingerprint: string;
|
|
24
27
|
stale?: true;
|
|
28
|
+
metadataWarning?: string;
|
|
25
29
|
}
|
|
26
30
|
| {
|
|
27
31
|
ok: false;
|
|
@@ -34,16 +38,20 @@ export interface GeminiLiveModelsOptions {
|
|
|
34
38
|
binary?: string;
|
|
35
39
|
env?: NodeJS.ProcessEnv;
|
|
36
40
|
deps?: AcpDeps;
|
|
41
|
+
packageRoot?: string;
|
|
37
42
|
ttlMs?: number;
|
|
43
|
+
forceRefresh?: boolean;
|
|
38
44
|
failureBackoffMs?: number;
|
|
39
45
|
now?: () => number;
|
|
40
46
|
}
|
|
41
47
|
|
|
42
48
|
interface CacheEntry {
|
|
43
49
|
models: string[];
|
|
50
|
+
modelInfo: OcxProviderModel[];
|
|
44
51
|
source: "models" | "default";
|
|
45
52
|
fetchedAt: number;
|
|
46
53
|
lastFailureAt?: number;
|
|
54
|
+
metadataWarning?: string;
|
|
47
55
|
}
|
|
48
56
|
|
|
49
57
|
const cache = new Map<string, CacheEntry>();
|
|
@@ -59,6 +67,10 @@ function staleResult(fingerprint: string, entry: CacheEntry | undefined): Gemini
|
|
|
59
67
|
return {
|
|
60
68
|
ok: true,
|
|
61
69
|
models: [...entry.models],
|
|
70
|
+
modelInfo: entry.modelInfo.map(model => ({
|
|
71
|
+
...model,
|
|
72
|
+
supportedReasoningLevels: [...(model.supportedReasoningLevels ?? [])],
|
|
73
|
+
})),
|
|
62
74
|
source: entry.source,
|
|
63
75
|
fingerprint,
|
|
64
76
|
stale: true,
|
|
@@ -79,8 +91,8 @@ export async function discoverGeminiLiveModels(options: GeminiLiveModelsOptions
|
|
|
79
91
|
const fingerprint = geminiBinaryFingerprintSync(binary);
|
|
80
92
|
const current = cache.get(fingerprint);
|
|
81
93
|
const timestamp = now();
|
|
82
|
-
if (current && timestamp - current.fetchedAt < ttlMs) {
|
|
83
|
-
return { ok: true, models: [...current.models], source: current.source, fingerprint };
|
|
94
|
+
if (!options.forceRefresh && current && timestamp - current.fetchedAt < ttlMs) {
|
|
95
|
+
return { ok: true, models: [...current.models], modelInfo: current.modelInfo, source: current.source, fingerprint, ...(current.metadataWarning ? { metadataWarning: current.metadataWarning } : {}) };
|
|
84
96
|
}
|
|
85
97
|
const lastFailure = failureCache.get(fingerprint) ?? current?.lastFailureAt;
|
|
86
98
|
if (lastFailure !== undefined && timestamp - lastFailure < failureBackoffMs) {
|
|
@@ -110,10 +122,18 @@ export async function discoverGeminiLiveModels(options: GeminiLiveModelsOptions
|
|
|
110
122
|
}, options.deps);
|
|
111
123
|
const session = await client.newSession({ cwd: workspace.cwd, mcpServers: [] });
|
|
112
124
|
const models = geminiModelIds(session);
|
|
125
|
+
const metadata = await discoverGeminiModelMetadata({
|
|
126
|
+
binary,
|
|
127
|
+
env: sourceEnv,
|
|
128
|
+
...(options.packageRoot ? { packageRoot: options.packageRoot } : {}),
|
|
129
|
+
...(options.now ? { now: options.now } : {}),
|
|
130
|
+
...(options.forceRefresh ? { ttlMs: 0 } : {}),
|
|
131
|
+
});
|
|
132
|
+
const modelInfo = mergeGeminiModelDescriptors(session.availableModels, metadata);
|
|
113
133
|
const source = session.availableModels.length > 0 ? "models" : "default";
|
|
114
|
-
cache.set(fingerprint, { models, source, fetchedAt: timestamp });
|
|
134
|
+
cache.set(fingerprint, { models, modelInfo, source, fetchedAt: timestamp, ...(metadata.warning ? { metadataWarning: metadata.warning } : {}) });
|
|
115
135
|
failureCache.delete(fingerprint);
|
|
116
|
-
return { ok: true, models, source, fingerprint };
|
|
136
|
+
return { ok: true, models, modelInfo, source, fingerprint, ...(metadata.warning ? { metadataWarning: metadata.warning } : {}) };
|
|
117
137
|
} catch (error) {
|
|
118
138
|
failureCache.set(fingerprint, timestamp);
|
|
119
139
|
if (current) current.lastFailureAt = timestamp;
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import { readFile, readdir, realpath, stat } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import type { OcxProviderModel, OcxReasoningLevel } from "../../../types";
|
|
5
|
+
|
|
6
|
+
const DEFAULT_METADATA_TTL_MS = 5 * 60_000;
|
|
7
|
+
|
|
8
|
+
export interface GeminiThinkingConfig {
|
|
9
|
+
thinkingLevel?: string;
|
|
10
|
+
thinkingBudget?: number;
|
|
11
|
+
includeThoughts?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface GeminiReasoningLevel extends OcxReasoningLevel {
|
|
15
|
+
thinkingConfig: GeminiThinkingConfig;
|
|
16
|
+
originalEffort: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface GeminiModelCapability {
|
|
20
|
+
thinkingSupported: boolean;
|
|
21
|
+
reasoningLevels: GeminiReasoningLevel[];
|
|
22
|
+
defaultReasoningLevel?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface GeminiMetadataResult {
|
|
26
|
+
packageRoot?: string;
|
|
27
|
+
settingsPath?: string;
|
|
28
|
+
capabilities: Record<string, GeminiModelCapability>;
|
|
29
|
+
warning?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface GeminiMetadataOptions {
|
|
33
|
+
binary: string;
|
|
34
|
+
env?: NodeJS.ProcessEnv;
|
|
35
|
+
packageRoot?: string;
|
|
36
|
+
ttlMs?: number;
|
|
37
|
+
now?: () => number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface CachedMetadata extends GeminiMetadataResult {
|
|
41
|
+
fetchedAt: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const metadataCache = new Map<string, CachedMetadata>();
|
|
45
|
+
|
|
46
|
+
export function resetGeminiMetadataCacheForTests(): void {
|
|
47
|
+
metadataCache.clear();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
51
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
52
|
+
? value as Record<string, unknown>
|
|
53
|
+
: undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function stringValue(value: unknown): string | undefined {
|
|
57
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function extractDefaultJsonValue(markdown: string, property: string): unknown {
|
|
61
|
+
const marker = `- **\`modelConfigs.${property}\`**`;
|
|
62
|
+
const start = markdown.indexOf(marker);
|
|
63
|
+
if (start < 0) return undefined;
|
|
64
|
+
const fenceStart = markdown.indexOf("```json", start);
|
|
65
|
+
if (fenceStart < 0) return undefined;
|
|
66
|
+
const bodyStart = markdown.indexOf("\n", fenceStart) + 1;
|
|
67
|
+
const fenceEnd = markdown.indexOf("```", bodyStart);
|
|
68
|
+
if (bodyStart <= 0 || fenceEnd < 0) return undefined;
|
|
69
|
+
try {
|
|
70
|
+
return JSON.parse(markdown.slice(bodyStart, fenceEnd).trim()) as unknown;
|
|
71
|
+
} catch {
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function extractDefaultJson(markdown: string, property: string): Record<string, unknown> | undefined {
|
|
77
|
+
return asRecord(extractDefaultJsonValue(markdown, property));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function findPackageRoot(binary: string): Promise<string | undefined> {
|
|
81
|
+
let current: string;
|
|
82
|
+
try {
|
|
83
|
+
current = dirname(await realpath(binary));
|
|
84
|
+
} catch {
|
|
85
|
+
current = dirname(resolve(binary));
|
|
86
|
+
}
|
|
87
|
+
for (let depth = 0; depth < 10; depth++) {
|
|
88
|
+
try {
|
|
89
|
+
const manifest = JSON.parse(await readFile(join(current, "package.json"), "utf8")) as { name?: unknown };
|
|
90
|
+
if (manifest.name === "@google/gemini-cli") return current;
|
|
91
|
+
} catch {
|
|
92
|
+
// Keep walking toward the package root.
|
|
93
|
+
}
|
|
94
|
+
const parent = dirname(current);
|
|
95
|
+
if (parent === current) break;
|
|
96
|
+
current = parent;
|
|
97
|
+
}
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function readThinkingLevels(packageRoot: string): Promise<Array<{ effort: string; original: string }>> {
|
|
102
|
+
let files: string[];
|
|
103
|
+
try {
|
|
104
|
+
files = (await readdir(join(packageRoot, "bundle"))).filter(name => name.endsWith(".js"));
|
|
105
|
+
} catch {
|
|
106
|
+
return [];
|
|
107
|
+
}
|
|
108
|
+
const candidates = await Promise.all(files.map(async name => {
|
|
109
|
+
const path = join(packageRoot, "bundle", name);
|
|
110
|
+
const info = await stat(path).catch(() => undefined);
|
|
111
|
+
return info ? { path, size: info.size } : undefined;
|
|
112
|
+
}));
|
|
113
|
+
candidates.sort((left, right) => (left?.size ?? Infinity) - (right?.size ?? Infinity));
|
|
114
|
+
for (const candidate of candidates) {
|
|
115
|
+
if (!candidate) continue;
|
|
116
|
+
const text = await readFile(candidate.path, "utf8").catch(() => "");
|
|
117
|
+
if (!text.includes("ThinkingLevel2[\"LOW\"]")) continue;
|
|
118
|
+
const levels = [...text.matchAll(/ThinkingLevel2\["([A-Z0-9_]+)"\]/g)]
|
|
119
|
+
.map(match => match[1]!)
|
|
120
|
+
.filter(level => level !== "THINKING_LEVEL_UNSPECIFIED");
|
|
121
|
+
return [...new Set(levels)].map(level => ({ effort: level.toLowerCase(), original: level }));
|
|
122
|
+
}
|
|
123
|
+
return [];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function mergeConfig(target: Record<string, unknown>, source: Record<string, unknown>): Record<string, unknown> {
|
|
127
|
+
const out = { ...target };
|
|
128
|
+
for (const [key, value] of Object.entries(source)) {
|
|
129
|
+
const targetRecord = asRecord(out[key]);
|
|
130
|
+
const sourceRecord = asRecord(value);
|
|
131
|
+
out[key] = targetRecord && sourceRecord ? mergeConfig(targetRecord, sourceRecord) : value;
|
|
132
|
+
}
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function resolveAlias(
|
|
137
|
+
id: string,
|
|
138
|
+
aliases: Record<string, unknown>,
|
|
139
|
+
seen = new Set<string>(),
|
|
140
|
+
): Record<string, unknown> {
|
|
141
|
+
if (seen.has(id)) return {};
|
|
142
|
+
seen.add(id);
|
|
143
|
+
const alias = asRecord(aliases[id]);
|
|
144
|
+
if (!alias) return {};
|
|
145
|
+
const parent = stringValue(alias.extends);
|
|
146
|
+
const inherited = parent ? resolveAlias(parent, aliases, seen) : {};
|
|
147
|
+
return mergeConfig(inherited, asRecord(alias.modelConfig) ?? {});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function capabilitiesFromMetadata(
|
|
151
|
+
definitions: Record<string, unknown>,
|
|
152
|
+
aliases: Record<string, unknown>,
|
|
153
|
+
levels: Array<{ effort: string; original: string }>,
|
|
154
|
+
overrides: readonly unknown[],
|
|
155
|
+
): Record<string, GeminiModelCapability> {
|
|
156
|
+
const capabilities: Record<string, GeminiModelCapability> = {};
|
|
157
|
+
for (const [modelId, rawDefinition] of Object.entries(definitions)) {
|
|
158
|
+
const definition = asRecord(rawDefinition);
|
|
159
|
+
const features = asRecord(definition?.features);
|
|
160
|
+
const thinkingSupported = features?.thinking === true;
|
|
161
|
+
if (!thinkingSupported) {
|
|
162
|
+
capabilities[modelId] = { thinkingSupported: false, reasoningLevels: [] };
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
let generateContentConfig = asRecord(resolveAlias(modelId, aliases).generateContentConfig) ?? {};
|
|
166
|
+
for (const rawOverride of overrides) {
|
|
167
|
+
const override = asRecord(rawOverride);
|
|
168
|
+
if (stringValue(asRecord(override?.match)?.model) !== modelId) continue;
|
|
169
|
+
generateContentConfig = mergeConfig(generateContentConfig, asRecord(asRecord(override?.modelConfig)?.generateContentConfig) ?? {});
|
|
170
|
+
}
|
|
171
|
+
const config = asRecord(generateContentConfig.thinkingConfig);
|
|
172
|
+
const configuredLevel = stringValue(config?.thinkingLevel)?.toLowerCase();
|
|
173
|
+
const includeThoughts = config?.includeThoughts === true;
|
|
174
|
+
const reasoningLevels = levels.map(level => ({
|
|
175
|
+
effort: level.effort,
|
|
176
|
+
originalEffort: level.original,
|
|
177
|
+
description: `Gemini CLI ${level.original} thinking level`,
|
|
178
|
+
thinkingConfig: {
|
|
179
|
+
thinkingLevel: level.original,
|
|
180
|
+
...(includeThoughts ? { includeThoughts: true } : {}),
|
|
181
|
+
},
|
|
182
|
+
}));
|
|
183
|
+
const defaultReasoningLevel = reasoningLevels.some(level => level.effort === configuredLevel)
|
|
184
|
+
? configuredLevel
|
|
185
|
+
: reasoningLevels.find(level => level.effort === "high")?.effort ?? reasoningLevels[0]?.effort;
|
|
186
|
+
capabilities[modelId] = {
|
|
187
|
+
thinkingSupported: true,
|
|
188
|
+
reasoningLevels,
|
|
189
|
+
...(defaultReasoningLevel ? { defaultReasoningLevel } : {}),
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
return capabilities;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function loadMetadata(options: GeminiMetadataOptions): Promise<GeminiMetadataResult> {
|
|
196
|
+
const env = options.env ?? process.env;
|
|
197
|
+
const packageRoot = options.packageRoot ?? await findPackageRoot(options.binary);
|
|
198
|
+
if (!packageRoot) return { capabilities: {}, warning: "Gemini CLI package metadata was not found." };
|
|
199
|
+
const docsPath = join(packageRoot, "bundle", "docs", "reference", "configuration.md");
|
|
200
|
+
let markdown: string;
|
|
201
|
+
try {
|
|
202
|
+
markdown = await readFile(docsPath, "utf8");
|
|
203
|
+
} catch {
|
|
204
|
+
return { packageRoot, capabilities: {}, warning: "Gemini CLI model configuration documentation was not found." };
|
|
205
|
+
}
|
|
206
|
+
const defaultAliases = extractDefaultJson(markdown, "aliases");
|
|
207
|
+
const defaultDefinitions = extractDefaultJson(markdown, "modelDefinitions");
|
|
208
|
+
const defaultOverrides = extractDefaultJsonValue(markdown, "overrides");
|
|
209
|
+
if (!defaultAliases || !defaultDefinitions) {
|
|
210
|
+
return { packageRoot, capabilities: {}, warning: "Gemini CLI model configuration metadata could not be parsed." };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const home = stringValue(env.GEMINI_CLI_HOME) ?? stringValue(env.HOME) ?? stringValue(env.USERPROFILE) ?? homedir();
|
|
214
|
+
const settingsPath = join(home, ".gemini", "settings.json");
|
|
215
|
+
let userSettings: Record<string, unknown> = {};
|
|
216
|
+
try {
|
|
217
|
+
userSettings = asRecord(JSON.parse(await readFile(settingsPath, "utf8"))) ?? {};
|
|
218
|
+
} catch {
|
|
219
|
+
// User settings are optional; absence is not a metadata failure.
|
|
220
|
+
}
|
|
221
|
+
const modelConfigs = asRecord(userSettings.modelConfigs) ?? {};
|
|
222
|
+
const aliases = mergeConfig(
|
|
223
|
+
mergeConfig(defaultAliases, asRecord(modelConfigs.customAliases) ?? {}),
|
|
224
|
+
asRecord(modelConfigs.aliases) ?? {},
|
|
225
|
+
);
|
|
226
|
+
const definitions = mergeConfig(defaultDefinitions, asRecord(modelConfigs.modelDefinitions) ?? {});
|
|
227
|
+
const overrides = [
|
|
228
|
+
...(Array.isArray(defaultOverrides) ? defaultOverrides : []),
|
|
229
|
+
...(Array.isArray(modelConfigs.overrides) ? modelConfigs.overrides : []),
|
|
230
|
+
...(Array.isArray(modelConfigs.customOverrides) ? modelConfigs.customOverrides : []),
|
|
231
|
+
];
|
|
232
|
+
const levels = await readThinkingLevels(packageRoot);
|
|
233
|
+
if (levels.length === 0) {
|
|
234
|
+
return { packageRoot, settingsPath, capabilities: {}, warning: "Gemini CLI thinking-level metadata was not found." };
|
|
235
|
+
}
|
|
236
|
+
return {
|
|
237
|
+
packageRoot,
|
|
238
|
+
settingsPath,
|
|
239
|
+
capabilities: capabilitiesFromMetadata(definitions, aliases, levels, overrides),
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export async function discoverGeminiModelMetadata(options: GeminiMetadataOptions): Promise<GeminiMetadataResult> {
|
|
244
|
+
const env = options.env ?? process.env;
|
|
245
|
+
const key = [
|
|
246
|
+
options.packageRoot ?? "auto",
|
|
247
|
+
resolve(options.binary),
|
|
248
|
+
stringValue(env.GEMINI_CLI_HOME) ?? stringValue(env.HOME) ?? stringValue(env.USERPROFILE) ?? homedir(),
|
|
249
|
+
].join("\n");
|
|
250
|
+
const now = options.now ?? Date.now;
|
|
251
|
+
const ttlMs = options.ttlMs ?? DEFAULT_METADATA_TTL_MS;
|
|
252
|
+
const cached = metadataCache.get(key);
|
|
253
|
+
if (cached && now() - cached.fetchedAt < ttlMs) return cached;
|
|
254
|
+
const result = await loadMetadata(options);
|
|
255
|
+
const entry: CachedMetadata = { ...result, fetchedAt: now() };
|
|
256
|
+
metadataCache.set(key, entry);
|
|
257
|
+
return entry;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function mergeGeminiModelDescriptors(
|
|
261
|
+
advertised: readonly { modelId: string; name?: string; description?: string }[],
|
|
262
|
+
metadata: GeminiMetadataResult,
|
|
263
|
+
): OcxProviderModel[] {
|
|
264
|
+
return advertised.map(model => {
|
|
265
|
+
const capability = metadata.capabilities[model.modelId];
|
|
266
|
+
const reasoningLevels = capability?.reasoningLevels.map(level => ({
|
|
267
|
+
effort: level.effort,
|
|
268
|
+
description: level.description,
|
|
269
|
+
})) ?? [];
|
|
270
|
+
return {
|
|
271
|
+
id: model.modelId,
|
|
272
|
+
...(model.name ? { name: model.name } : {}),
|
|
273
|
+
...(model.description ? { description: model.description } : {}),
|
|
274
|
+
supportedReasoningLevels: reasoningLevels,
|
|
275
|
+
...(capability?.defaultReasoningLevel ? { defaultReasoningLevel: capability.defaultReasoningLevel } : {}),
|
|
276
|
+
};
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export function reasoningLevelForEffort(
|
|
281
|
+
capability: GeminiModelCapability | undefined,
|
|
282
|
+
effort: string | undefined,
|
|
283
|
+
): GeminiReasoningLevel | undefined {
|
|
284
|
+
if (!capability?.thinkingSupported || capability.reasoningLevels.length === 0) return undefined;
|
|
285
|
+
const wanted = effort?.trim().toLowerCase();
|
|
286
|
+
if (!wanted) {
|
|
287
|
+
return capability.reasoningLevels.find(level => level.effort === capability.defaultReasoningLevel)
|
|
288
|
+
?? capability.reasoningLevels[0];
|
|
289
|
+
}
|
|
290
|
+
return capability.reasoningLevels.find(level => level.effort === wanted);
|
|
291
|
+
}
|
package/src/cli/index.ts
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
import { startServer } from "../server";
|
|
20
20
|
import { loginClineAcp } from "../adapters/cli-agent/cline/acp";
|
|
21
21
|
import { runCodingAgentMcpServer } from "../adapters/cli-agent/mcp-server";
|
|
22
|
+
import { syncCodexCatalog } from "../lib/codex-catalog";
|
|
22
23
|
|
|
23
24
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
24
25
|
|
|
@@ -61,6 +62,7 @@ function help(): string {
|
|
|
61
62
|
" start [--port <port>] [--host <host>] Start the proxy",
|
|
62
63
|
" status [--json] Check the running proxy",
|
|
63
64
|
" stop [--json] Stop the running proxy",
|
|
65
|
+
" codex sync [--json] Refresh the Codex model catalog",
|
|
64
66
|
" version Print the package version",
|
|
65
67
|
" help Show this help",
|
|
66
68
|
"",
|
|
@@ -177,6 +179,20 @@ async function startCommand(args: string[]): Promise<number> {
|
|
|
177
179
|
config.port = parsed;
|
|
178
180
|
}
|
|
179
181
|
if (host !== undefined) config.host = host;
|
|
182
|
+
try {
|
|
183
|
+
const catalog = await syncCodexCatalog({ env });
|
|
184
|
+
if (catalog.ok && catalog.written) {
|
|
185
|
+
console.log(`Codex catalog refreshed: ${catalog.path}`);
|
|
186
|
+
console.log("Restart the Codex app/app-server to reload the model picker.");
|
|
187
|
+
if (!catalog.configured) {
|
|
188
|
+
console.log(`Activate it with: model_catalog_json = "${catalog.path}"`);
|
|
189
|
+
}
|
|
190
|
+
} else if (!catalog.ok) {
|
|
191
|
+
console.warn(`[opencodex] Codex catalog was not refreshed: ${catalog.message}`);
|
|
192
|
+
}
|
|
193
|
+
} catch (error) {
|
|
194
|
+
console.warn(`[opencodex] Codex catalog refresh failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
195
|
+
}
|
|
180
196
|
const running = startServer(config, { env });
|
|
181
197
|
await writeRuntimeState({ pid: process.pid, port: running.port, host: running.host, startedAt: new Date().toISOString() }, env);
|
|
182
198
|
console.log(`opencodex listening on http://${running.host}:${running.port}/v1 (default provider: ${config.defaultProvider})`);
|
|
@@ -194,6 +210,31 @@ async function startCommand(args: string[]): Promise<number> {
|
|
|
194
210
|
return 0;
|
|
195
211
|
}
|
|
196
212
|
|
|
213
|
+
async function codexCommand(args: string[]): Promise<number> {
|
|
214
|
+
const sub = args.shift();
|
|
215
|
+
if (sub !== "sync") throw new ConfigError(`Unknown codex command: ${sub ?? "(missing)"}. Expected sync.`);
|
|
216
|
+
const json = takeFlag(args, "--json");
|
|
217
|
+
if (args.length > 0) throw new ConfigError(`Unknown codex sync option: ${args[0]}`);
|
|
218
|
+
const result = await syncCodexCatalog({ env: process.env });
|
|
219
|
+
if (!result.ok) {
|
|
220
|
+
if (json) console.log(JSON.stringify({ ok: false, path: result.path, reason: result.reason, message: result.message }));
|
|
221
|
+
else console.error(`ocx: ${result.message}`);
|
|
222
|
+
return 1;
|
|
223
|
+
}
|
|
224
|
+
if (json) {
|
|
225
|
+
console.log(JSON.stringify(result));
|
|
226
|
+
} else {
|
|
227
|
+
console.log(`Catalog: ${result.path}`);
|
|
228
|
+
console.log(`Models: ${result.modelCount} (Gemini ${result.geminiModelCount}, reasoning-enabled ${result.reasoningModelCount})`);
|
|
229
|
+
for (const warning of result.warnings) console.warn(`[opencodex] ${warning}`);
|
|
230
|
+
if (!result.configured) {
|
|
231
|
+
console.log(`Activate it with: model_catalog_json = "${result.path}"`);
|
|
232
|
+
}
|
|
233
|
+
console.log("Codex reload required: restart the Codex app or app-server model picker.");
|
|
234
|
+
}
|
|
235
|
+
return 0;
|
|
236
|
+
}
|
|
237
|
+
|
|
197
238
|
async function statusCommand(args: string[]): Promise<number> {
|
|
198
239
|
const json = takeFlag(args, "--json");
|
|
199
240
|
if (args.length > 0) throw new ConfigError(`Unknown status option: ${args[0]}`);
|
|
@@ -256,10 +297,11 @@ async function main(argv: string[]): Promise<number> {
|
|
|
256
297
|
}
|
|
257
298
|
if (command === "login") return await loginCommand(argv);
|
|
258
299
|
if (command === "provider") return await providerCommand(argv);
|
|
300
|
+
if (command === "codex") return await codexCommand(argv);
|
|
259
301
|
if (command === "start") return await startCommand(argv);
|
|
260
302
|
if (command === "status") return await statusCommand(argv);
|
|
261
303
|
if (command === "stop") return await stopCommand(argv);
|
|
262
|
-
throw new ConfigError(`Unknown command: ${command}. Expected login, provider, start, status, stop, version, or help.`);
|
|
304
|
+
throw new ConfigError(`Unknown command: ${command}. Expected login, provider, codex, start, status, stop, version, or help.`);
|
|
263
305
|
}
|
|
264
306
|
|
|
265
307
|
try {
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
4
|
+
import type { OcxProviderModel } from "../types";
|
|
5
|
+
import { discoverClineLiveModels } from "../adapters/cli-agent/cline/live-models";
|
|
6
|
+
import { discoverGeminiLiveModels } from "../adapters/cli-agent/gemini/live-models";
|
|
7
|
+
import type { ClineTurnDeps } from "../adapters/cli-agent/cline/adapter";
|
|
8
|
+
import type { GeminiTurnDeps } from "../adapters/cli-agent/gemini/adapter";
|
|
9
|
+
|
|
10
|
+
const CATALOG_FALLBACK_CONTEXT = 128_000;
|
|
11
|
+
const BASE_INSTRUCTIONS = "You are a helpful coding assistant.";
|
|
12
|
+
const MANAGED_PREFIXES = ["cline-cli/", "gemini-cli/"] as const;
|
|
13
|
+
|
|
14
|
+
export interface CodexCatalogModel {
|
|
15
|
+
slug: string;
|
|
16
|
+
display_name: string;
|
|
17
|
+
description: string;
|
|
18
|
+
default_reasoning_level: string;
|
|
19
|
+
supported_reasoning_levels: Array<{ effort: string; description: string }>;
|
|
20
|
+
shell_type: "unified_exec";
|
|
21
|
+
visibility: "list";
|
|
22
|
+
supported_in_api: true;
|
|
23
|
+
priority: number;
|
|
24
|
+
base_instructions: string;
|
|
25
|
+
web_search_tool_type: "text_and_image";
|
|
26
|
+
supports_search_tool: false;
|
|
27
|
+
tool_mode: "code_mode_only";
|
|
28
|
+
context_window: number;
|
|
29
|
+
max_context_window: number;
|
|
30
|
+
auto_compact_token_limit: number;
|
|
31
|
+
input_modalities: ["text"];
|
|
32
|
+
node_repl_disabled: false;
|
|
33
|
+
node_repl_auto_review_required: false;
|
|
34
|
+
include_plugin_usage_instructions: false;
|
|
35
|
+
include_apps_usage_instructions: false;
|
|
36
|
+
include_skills_usage_instructions: false;
|
|
37
|
+
supports_reasoning_summaries: boolean;
|
|
38
|
+
default_reasoning_summary: string;
|
|
39
|
+
support_verbosity: true;
|
|
40
|
+
default_verbosity: "low";
|
|
41
|
+
apply_patch_tool_type: "freeform";
|
|
42
|
+
truncation_policy: { mode: "tokens"; limit: number };
|
|
43
|
+
supports_parallel_tool_calls: true;
|
|
44
|
+
supports_image_detail_original: false;
|
|
45
|
+
experimental_supported_tools: [];
|
|
46
|
+
effective_context_window_percent: 95;
|
|
47
|
+
comp_hash: null;
|
|
48
|
+
multi_agent_version: "v1";
|
|
49
|
+
auto_review_model_override: null;
|
|
50
|
+
supports_reasoning_effort_updates: false;
|
|
51
|
+
use_responses_lite: false;
|
|
52
|
+
additional_speed_tiers: [];
|
|
53
|
+
service_tiers: [];
|
|
54
|
+
availability_nux: null;
|
|
55
|
+
upgrade: null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface CodexCatalogDocument {
|
|
59
|
+
fetched_at: string;
|
|
60
|
+
etag: null;
|
|
61
|
+
client_version: string;
|
|
62
|
+
identity: string;
|
|
63
|
+
models: CodexCatalogModel[];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface CodexCatalogDiscoveryOptions {
|
|
67
|
+
env?: NodeJS.ProcessEnv;
|
|
68
|
+
clineDeps?: ClineTurnDeps;
|
|
69
|
+
geminiDeps?: GeminiTurnDeps;
|
|
70
|
+
geminiBinary?: string;
|
|
71
|
+
geminiPackageRoot?: string;
|
|
72
|
+
now?: Date;
|
|
73
|
+
forceRefresh?: boolean;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface CodexCatalogModels {
|
|
77
|
+
models: CodexCatalogModel[];
|
|
78
|
+
warnings: string[];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export type CodexCatalogSyncResult =
|
|
82
|
+
| {
|
|
83
|
+
ok: true;
|
|
84
|
+
path: string;
|
|
85
|
+
configured: boolean;
|
|
86
|
+
written: boolean;
|
|
87
|
+
modelCount: number;
|
|
88
|
+
geminiModelCount: number;
|
|
89
|
+
reasoningModelCount: number;
|
|
90
|
+
reloadRequired: true;
|
|
91
|
+
warnings: string[];
|
|
92
|
+
}
|
|
93
|
+
| {
|
|
94
|
+
ok: false;
|
|
95
|
+
path?: string;
|
|
96
|
+
reason: "unsafe_path" | "malformed_catalog" | "write_failed" | "discovery_failed";
|
|
97
|
+
message: string;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
101
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
102
|
+
? value as Record<string, unknown>
|
|
103
|
+
: undefined;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function encodeRoutedId(id: string): string {
|
|
107
|
+
return id.replaceAll("/", "-");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function buildCodexCatalogModel(providerId: "cline-cli" | "gemini-cli", model: OcxProviderModel, index: number): CodexCatalogModel {
|
|
111
|
+
const levels = (model.supportedReasoningLevels ?? []).map(level => ({
|
|
112
|
+
effort: level.effort,
|
|
113
|
+
description: level.description,
|
|
114
|
+
}));
|
|
115
|
+
const defaultLevel = model.defaultReasoningLevel ?? levels[0]?.effort ?? "low";
|
|
116
|
+
const isDefault = model.id === "default";
|
|
117
|
+
const providerLabel = providerId === "gemini-cli" ? "Gemini CLI" : "Cline CLI";
|
|
118
|
+
return {
|
|
119
|
+
slug: `${providerId}/${encodeRoutedId(model.id)}`,
|
|
120
|
+
display_name: model.name ?? (isDefault ? `${providerLabel} default` : model.id),
|
|
121
|
+
description: model.description ?? `Routed via opencodex -> ${providerLabel}.`,
|
|
122
|
+
default_reasoning_level: levels.length > 0 ? defaultLevel : "low",
|
|
123
|
+
supported_reasoning_levels: levels,
|
|
124
|
+
shell_type: "unified_exec",
|
|
125
|
+
visibility: "list",
|
|
126
|
+
supported_in_api: true,
|
|
127
|
+
priority: 10 + index,
|
|
128
|
+
base_instructions: BASE_INSTRUCTIONS,
|
|
129
|
+
web_search_tool_type: "text_and_image",
|
|
130
|
+
supports_search_tool: false,
|
|
131
|
+
tool_mode: "code_mode_only",
|
|
132
|
+
context_window: CATALOG_FALLBACK_CONTEXT,
|
|
133
|
+
max_context_window: CATALOG_FALLBACK_CONTEXT,
|
|
134
|
+
auto_compact_token_limit: Math.floor(CATALOG_FALLBACK_CONTEXT * 0.9),
|
|
135
|
+
input_modalities: ["text"],
|
|
136
|
+
node_repl_disabled: false,
|
|
137
|
+
node_repl_auto_review_required: false,
|
|
138
|
+
include_plugin_usage_instructions: false,
|
|
139
|
+
include_apps_usage_instructions: false,
|
|
140
|
+
include_skills_usage_instructions: false,
|
|
141
|
+
supports_reasoning_summaries: levels.length > 0,
|
|
142
|
+
default_reasoning_summary: levels.length > 0 ? "auto" : "none",
|
|
143
|
+
support_verbosity: true,
|
|
144
|
+
default_verbosity: "low",
|
|
145
|
+
apply_patch_tool_type: "freeform",
|
|
146
|
+
truncation_policy: { mode: "tokens", limit: 10_000 },
|
|
147
|
+
supports_parallel_tool_calls: true,
|
|
148
|
+
supports_image_detail_original: false,
|
|
149
|
+
experimental_supported_tools: [],
|
|
150
|
+
effective_context_window_percent: 95,
|
|
151
|
+
comp_hash: null,
|
|
152
|
+
multi_agent_version: "v1",
|
|
153
|
+
auto_review_model_override: null,
|
|
154
|
+
supports_reasoning_effort_updates: false,
|
|
155
|
+
use_responses_lite: false,
|
|
156
|
+
additional_speed_tiers: [],
|
|
157
|
+
service_tiers: [],
|
|
158
|
+
availability_nux: null,
|
|
159
|
+
upgrade: null,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function defaultModel(): OcxProviderModel {
|
|
164
|
+
return { id: "default" };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function normalizedModels(models: readonly OcxProviderModel[]): OcxProviderModel[] {
|
|
168
|
+
const seen = new Set<string>();
|
|
169
|
+
const out: OcxProviderModel[] = [];
|
|
170
|
+
for (const model of [defaultModel(), ...models]) {
|
|
171
|
+
if (!seen.has(model.id)) {
|
|
172
|
+
seen.add(model.id);
|
|
173
|
+
out.push(model);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return out;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export async function discoverCodexCatalogModels(options: CodexCatalogDiscoveryOptions = {}): Promise<CodexCatalogModels> {
|
|
180
|
+
const env = options.env ?? process.env;
|
|
181
|
+
const [cline, gemini] = await Promise.all([
|
|
182
|
+
discoverClineLiveModels({ env, ...(options.clineDeps ? { deps: options.clineDeps } : {}) }),
|
|
183
|
+
discoverGeminiLiveModels({
|
|
184
|
+
env,
|
|
185
|
+
...(options.geminiBinary ? { binary: options.geminiBinary } : {}),
|
|
186
|
+
...(options.geminiDeps ? { deps: options.geminiDeps } : {}),
|
|
187
|
+
...(options.geminiPackageRoot ? { packageRoot: options.geminiPackageRoot } : {}),
|
|
188
|
+
...(options.forceRefresh ? { forceRefresh: true } : {}),
|
|
189
|
+
}),
|
|
190
|
+
]);
|
|
191
|
+
const warnings: string[] = [];
|
|
192
|
+
if (!cline.ok && cline.reason !== "not_found" && env.OPENCODEX_CLINE_ACP?.trim() !== "0") warnings.push(`Cline: ${cline.detail}`);
|
|
193
|
+
if (!gemini.ok && gemini.reason !== "not_found" && env.OPENCODEX_GEMINI_ACP?.trim() !== "0") warnings.push(`Gemini: ${gemini.detail}`);
|
|
194
|
+
if (gemini.ok && gemini.metadataWarning) warnings.push(`Gemini metadata: ${gemini.metadataWarning}`);
|
|
195
|
+
const clineModels = normalizedModels(cline.ok ? cline.models.map(id => ({ id })) : []);
|
|
196
|
+
const geminiModels = normalizedModels(gemini.ok ? gemini.modelInfo : []);
|
|
197
|
+
return {
|
|
198
|
+
models: [
|
|
199
|
+
...clineModels.map((model, index) => buildCodexCatalogModel("cline-cli", model, index)),
|
|
200
|
+
...geminiModels.map((model, index) => buildCodexCatalogModel("gemini-cli", model, clineModels.length + index)),
|
|
201
|
+
],
|
|
202
|
+
warnings,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function buildCodexCatalogDocument(
|
|
207
|
+
models: CodexCatalogModel[],
|
|
208
|
+
now = new Date(),
|
|
209
|
+
preserved: CodexCatalogModel[] = [],
|
|
210
|
+
): CodexCatalogDocument {
|
|
211
|
+
return {
|
|
212
|
+
fetched_at: now.toISOString(),
|
|
213
|
+
etag: null,
|
|
214
|
+
client_version: "0.165.0",
|
|
215
|
+
identity: "opencodex",
|
|
216
|
+
models: [...preserved, ...models],
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function readRootTomlString(text: string, key: string): string | undefined {
|
|
221
|
+
const firstTable = text.search(/^\s*\[/m);
|
|
222
|
+
const root = firstTable < 0 ? text : text.slice(0, firstTable);
|
|
223
|
+
const match = new RegExp(`^\\s*${key}\\s*=\\s*"([^"]+)"`, "m").exec(root);
|
|
224
|
+
return match?.[1]?.trim() || undefined;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export async function writeCodexCatalog(
|
|
228
|
+
path: string,
|
|
229
|
+
document: CodexCatalogDocument,
|
|
230
|
+
): Promise<void> {
|
|
231
|
+
const parent = dirname(path);
|
|
232
|
+
await mkdir(parent, { recursive: true, mode: 0o700 });
|
|
233
|
+
const temporary = `${path}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
|
234
|
+
try {
|
|
235
|
+
await writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
236
|
+
await chmod(temporary, 0o600);
|
|
237
|
+
await rename(temporary, path);
|
|
238
|
+
await chmod(path, 0o600).catch(() => undefined);
|
|
239
|
+
} finally {
|
|
240
|
+
await rm(temporary, { force: true }).catch(() => undefined);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function managedSlug(slug: unknown): boolean {
|
|
245
|
+
return typeof slug === "string" && MANAGED_PREFIXES.some(prefix => slug.startsWith(prefix));
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export async function syncCodexCatalog(
|
|
249
|
+
options: CodexCatalogDiscoveryOptions & {
|
|
250
|
+
path?: string;
|
|
251
|
+
discover?: typeof discoverCodexCatalogModels;
|
|
252
|
+
} = {},
|
|
253
|
+
): Promise<CodexCatalogSyncResult> {
|
|
254
|
+
const env = options.env ?? process.env;
|
|
255
|
+
const codexHome = env.CODEX_HOME?.trim() || join(homedir(), ".codex");
|
|
256
|
+
let configuredPath: string | undefined;
|
|
257
|
+
try {
|
|
258
|
+
configuredPath = readRootTomlString(await readFile(join(codexHome, "config.toml"), "utf8"), "model_catalog_json");
|
|
259
|
+
} catch {
|
|
260
|
+
configuredPath = undefined;
|
|
261
|
+
}
|
|
262
|
+
const configured = configuredPath !== undefined;
|
|
263
|
+
const selected = options.path
|
|
264
|
+
? (isAbsolute(options.path) ? options.path : resolve(options.path))
|
|
265
|
+
: configuredPath
|
|
266
|
+
? resolve(codexHome, configuredPath)
|
|
267
|
+
: join(codexHome, "opencodex-catalog.json");
|
|
268
|
+
if (configured && basename(selected) !== "opencodex-catalog.json") {
|
|
269
|
+
return { ok: false, path: selected, reason: "unsafe_path", message: "Configured model_catalog_json is not an opencodex catalog; refusing to overwrite it." };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
let existing: Record<string, unknown> = {};
|
|
273
|
+
try {
|
|
274
|
+
const parsed = asRecord(JSON.parse(await readFile(selected, "utf8")));
|
|
275
|
+
if (!parsed) return { ok: false, path: selected, reason: "malformed_catalog", message: "Existing Codex catalog is not a JSON object." };
|
|
276
|
+
existing = parsed;
|
|
277
|
+
} catch (error) {
|
|
278
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
279
|
+
return { ok: false, path: selected, reason: "malformed_catalog", message: "Existing Codex catalog could not be parsed; it was preserved." };
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
let discovered: CodexCatalogModels;
|
|
284
|
+
try {
|
|
285
|
+
discovered = await (options.discover ?? discoverCodexCatalogModels)({ ...options, forceRefresh: true });
|
|
286
|
+
} catch (error) {
|
|
287
|
+
return { ok: false, reason: "discovery_failed", message: error instanceof Error ? error.message : String(error) };
|
|
288
|
+
}
|
|
289
|
+
const existingModels = Array.isArray(existing.models) ? existing.models : [];
|
|
290
|
+
if (existing.models !== undefined && !Array.isArray(existing.models)) {
|
|
291
|
+
return { ok: false, path: selected, reason: "malformed_catalog", message: "Existing Codex catalog models field is not an array; it was preserved." };
|
|
292
|
+
}
|
|
293
|
+
if (existingModels.some(model => !asRecord(model))) {
|
|
294
|
+
return { ok: false, path: selected, reason: "malformed_catalog", message: "Existing Codex catalog contains a non-object model row; it was preserved." };
|
|
295
|
+
}
|
|
296
|
+
const preserved = existingModels.filter(model => !managedSlug(asRecord(model)?.slug));
|
|
297
|
+
const document = buildCodexCatalogDocument(
|
|
298
|
+
discovered.models,
|
|
299
|
+
options.now ?? new Date(),
|
|
300
|
+
preserved as CodexCatalogModel[],
|
|
301
|
+
);
|
|
302
|
+
try {
|
|
303
|
+
await writeCodexCatalog(selected, document);
|
|
304
|
+
} catch (error) {
|
|
305
|
+
return { ok: false, path: selected, reason: "write_failed", message: error instanceof Error ? error.message : String(error) };
|
|
306
|
+
}
|
|
307
|
+
return {
|
|
308
|
+
ok: true,
|
|
309
|
+
path: selected,
|
|
310
|
+
configured,
|
|
311
|
+
written: true,
|
|
312
|
+
modelCount: document.models.length,
|
|
313
|
+
geminiModelCount: document.models.filter(model => model.slug.startsWith("gemini-cli/")).length,
|
|
314
|
+
reasoningModelCount: document.models.filter(model => model.slug.startsWith("gemini-cli/") && (model.supported_reasoning_levels?.length ?? 0) > 0).length,
|
|
315
|
+
reloadRequired: true,
|
|
316
|
+
warnings: discovered.warnings,
|
|
317
|
+
};
|
|
318
|
+
}
|
package/src/server/index.ts
CHANGED
|
@@ -12,6 +12,7 @@ import type { ClineTurnDeps } from "../adapters/cli-agent/cline/adapter";
|
|
|
12
12
|
import type { GeminiTurnDeps } from "../adapters/cli-agent/gemini/adapter";
|
|
13
13
|
import { discoverClineLiveModels } from "../adapters/cli-agent/cline/live-models";
|
|
14
14
|
import { discoverGeminiLiveModels } from "../adapters/cli-agent/gemini/live-models";
|
|
15
|
+
import { buildCodexCatalogDocument, discoverCodexCatalogModels } from "../lib/codex-catalog";
|
|
15
16
|
import { handleResponses, modelList, RequestValidationError, UnsupportedMediaError, type ProviderModels } from "./responses";
|
|
16
17
|
import { handleCountTokens, handleMessages } from "./messages";
|
|
17
18
|
|
|
@@ -22,6 +23,7 @@ export interface ServerOptions {
|
|
|
22
23
|
env?: NodeJS.ProcessEnv;
|
|
23
24
|
clineDeps?: ClineTurnDeps;
|
|
24
25
|
geminiDeps?: GeminiTurnDeps;
|
|
26
|
+
geminiPackageRoot?: string;
|
|
25
27
|
}
|
|
26
28
|
|
|
27
29
|
export interface RunningServer {
|
|
@@ -80,10 +82,11 @@ async function discoverModels(
|
|
|
80
82
|
env: NodeJS.ProcessEnv,
|
|
81
83
|
clineDeps: ClineTurnDeps | undefined,
|
|
82
84
|
geminiDeps: GeminiTurnDeps | undefined,
|
|
85
|
+
geminiPackageRoot: string | undefined,
|
|
83
86
|
): Promise<ProviderModels> {
|
|
84
87
|
const [cline, gemini] = await Promise.all([
|
|
85
88
|
discoverClineLiveModels({ env, ...(clineDeps ? { deps: clineDeps } : {}) }),
|
|
86
|
-
discoverGeminiLiveModels({ env, ...(geminiDeps ? { deps: geminiDeps } : {}) }),
|
|
89
|
+
discoverGeminiLiveModels({ env, ...(geminiDeps ? { deps: geminiDeps } : {}), ...(geminiPackageRoot ? { packageRoot: geminiPackageRoot } : {}) }),
|
|
87
90
|
]);
|
|
88
91
|
if (!cline.ok && cline.reason !== "not_found" && env.OPENCODEX_CLINE_ACP?.trim() !== "0") {
|
|
89
92
|
console.warn(`[opencodex] Cline model discovery degraded [${cline.reason}]: ${cline.detail}`);
|
|
@@ -93,7 +96,9 @@ async function discoverModels(
|
|
|
93
96
|
}
|
|
94
97
|
return {
|
|
95
98
|
"cline-cli": cline.ok ? ["default", ...cline.models.filter(id => id !== "default")] : ["default"],
|
|
96
|
-
"gemini-cli": gemini.ok
|
|
99
|
+
"gemini-cli": gemini.ok
|
|
100
|
+
? ["default", ...gemini.modelInfo.filter(model => model.id !== "default")]
|
|
101
|
+
: ["default"],
|
|
97
102
|
};
|
|
98
103
|
}
|
|
99
104
|
|
|
@@ -119,19 +124,28 @@ export function startServer(config: RuntimeConfig | number, options: ServerOptio
|
|
|
119
124
|
}
|
|
120
125
|
try {
|
|
121
126
|
if (req.method === "GET" && url.pathname === "/v1/models") {
|
|
122
|
-
const known = await discoverModels(env, options.clineDeps, options.geminiDeps);
|
|
127
|
+
const known = await discoverModels(env, options.clineDeps, options.geminiDeps, options.geminiPackageRoot);
|
|
123
128
|
return Response.json({ object: "list", data: modelList(known) });
|
|
124
129
|
}
|
|
130
|
+
if (req.method === "GET" && url.pathname === "/v1/model-catalog") {
|
|
131
|
+
const discovered = await discoverCodexCatalogModels({
|
|
132
|
+
env,
|
|
133
|
+
...(options.clineDeps ? { clineDeps: options.clineDeps } : {}),
|
|
134
|
+
...(options.geminiDeps ? { geminiDeps: options.geminiDeps } : {}),
|
|
135
|
+
...(options.geminiPackageRoot ? { geminiPackageRoot: options.geminiPackageRoot } : {}),
|
|
136
|
+
});
|
|
137
|
+
return Response.json(buildCodexCatalogDocument(discovered.models));
|
|
138
|
+
}
|
|
125
139
|
if (req.method === "POST" && url.pathname === "/v1/responses") {
|
|
126
140
|
const body = await boundedJson(req);
|
|
127
|
-
const known = await discoverModels(env, options.clineDeps, options.geminiDeps);
|
|
141
|
+
const known = await discoverModels(env, options.clineDeps, options.geminiDeps, options.geminiPackageRoot);
|
|
128
142
|
const signal = AbortSignal.any([req.signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)]);
|
|
129
143
|
const incoming: IncomingMeta = { headers: req.headers, abortSignal: signal };
|
|
130
144
|
return await handleResponses(body, incoming, known, turnDeps, config.defaultProvider);
|
|
131
145
|
}
|
|
132
146
|
if (req.method === "POST" && url.pathname === "/v1/messages") {
|
|
133
147
|
const body = await boundedJson(req);
|
|
134
|
-
const known = await discoverModels(env, options.clineDeps, options.geminiDeps);
|
|
148
|
+
const known = await discoverModels(env, options.clineDeps, options.geminiDeps, options.geminiPackageRoot);
|
|
135
149
|
const signal = AbortSignal.any([req.signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)]);
|
|
136
150
|
return await handleMessages(body, { headers: req.headers, abortSignal: signal }, known, turnDeps, config.defaultProvider);
|
|
137
151
|
}
|
package/src/server/responses.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
OcxTool,
|
|
7
7
|
OcxToolChoice,
|
|
8
8
|
OcxUsage,
|
|
9
|
+
OcxProviderModel,
|
|
9
10
|
} from "../types";
|
|
10
11
|
import { runClineTurn, type ClineTurnDeps } from "../adapters/cli-agent/cline/adapter";
|
|
11
12
|
import { runGeminiTurn, type GeminiTurnDeps } from "../adapters/cli-agent/gemini/adapter";
|
|
@@ -275,7 +276,8 @@ export function resolveClineModel(requested: string, known: Iterable<string>): s
|
|
|
275
276
|
return decodeRoutedModelId(suffix, known);
|
|
276
277
|
}
|
|
277
278
|
|
|
278
|
-
export type
|
|
279
|
+
export type ProviderModelEntry = string | OcxProviderModel;
|
|
280
|
+
export type ProviderModels = Partial<Record<ProviderId, readonly ProviderModelEntry[]>>;
|
|
279
281
|
|
|
280
282
|
export interface RoutedModel {
|
|
281
283
|
providerId: ProviderId;
|
|
@@ -297,10 +299,16 @@ export function resolveRoutedModel(
|
|
|
297
299
|
const suffix = requested.startsWith(prefix) ? requested.slice(prefix.length) : requested;
|
|
298
300
|
return {
|
|
299
301
|
providerId,
|
|
300
|
-
modelId: suffix === "default"
|
|
302
|
+
modelId: suffix === "default"
|
|
303
|
+
? "default"
|
|
304
|
+
: decodeRoutedModelId(suffix, (known[providerId] ?? []).map(modelIdOf)),
|
|
301
305
|
};
|
|
302
306
|
}
|
|
303
307
|
|
|
308
|
+
function modelIdOf(model: ProviderModelEntry): string {
|
|
309
|
+
return typeof model === "string" ? model : model.id;
|
|
310
|
+
}
|
|
311
|
+
|
|
304
312
|
const PROVIDER_LABELS: Record<ProviderId, string> = {
|
|
305
313
|
[CLINE_PROVIDER_ID]: "Cline CLI",
|
|
306
314
|
[GEMINI_PROVIDER_ID]: "Gemini CLI",
|
|
@@ -325,14 +333,30 @@ export function modelList(known: string[] | ProviderModels): Array<Record<string
|
|
|
325
333
|
display_name: "Configured default provider",
|
|
326
334
|
}];
|
|
327
335
|
for (const providerId of [CLINE_PROVIDER_ID, GEMINI_PROVIDER_ID] as const) {
|
|
328
|
-
const
|
|
329
|
-
|
|
336
|
+
const entries = ["default", ...(known[providerId] ?? []).filter(entry => modelIdOf(entry) !== "default")];
|
|
337
|
+
const seen = new Set<string>();
|
|
338
|
+
for (const entry of entries) {
|
|
339
|
+
const id = modelIdOf(entry);
|
|
340
|
+
if (seen.has(id)) continue;
|
|
341
|
+
seen.add(id);
|
|
342
|
+
const info = typeof entry === "string" ? { id: entry } as OcxProviderModel : entry;
|
|
343
|
+
const reasoning = info.supportedReasoningLevels ?? [];
|
|
330
344
|
rows.push({
|
|
331
345
|
id: `${providerId}/${encodeRoutedModelId(id)}`,
|
|
332
346
|
object: "model",
|
|
333
347
|
created: 0,
|
|
334
348
|
owned_by: providerId,
|
|
335
|
-
display_name: id === "default" ? `${PROVIDER_LABELS[providerId]} default` : id,
|
|
349
|
+
display_name: info.name ?? (id === "default" ? `${PROVIDER_LABELS[providerId]} default` : id),
|
|
350
|
+
...(info.description ? { description: info.description } : {}),
|
|
351
|
+
...(reasoning.length > 0
|
|
352
|
+
? {
|
|
353
|
+
supported_reasoning_levels: reasoning.map(level => ({
|
|
354
|
+
effort: level.effort,
|
|
355
|
+
description: level.description,
|
|
356
|
+
})),
|
|
357
|
+
default_reasoning_level: info.defaultReasoningLevel ?? reasoning[0]!.effort,
|
|
358
|
+
}
|
|
359
|
+
: { supported_reasoning_levels: [] }),
|
|
336
360
|
});
|
|
337
361
|
}
|
|
338
362
|
}
|
package/src/types.ts
CHANGED
|
@@ -96,6 +96,19 @@ export interface OcxUsage {
|
|
|
96
96
|
cacheCreationInputTokens?: number;
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
+
export interface OcxReasoningLevel {
|
|
100
|
+
effort: string;
|
|
101
|
+
description: string;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface OcxProviderModel {
|
|
105
|
+
id: string;
|
|
106
|
+
name?: string;
|
|
107
|
+
description?: string;
|
|
108
|
+
supportedReasoningLevels?: readonly OcxReasoningLevel[];
|
|
109
|
+
defaultReasoningLevel?: string;
|
|
110
|
+
}
|
|
111
|
+
|
|
99
112
|
export type AdapterEvent =
|
|
100
113
|
| { type: "heartbeat" }
|
|
101
114
|
| { type: "text_delta"; text: string }
|